mirror of
https://github.com/apache/superset.git
synced 2026-06-10 01:59:17 +00:00
Compare commits
30 Commits
enxdev/fea
...
enxdev/cha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c65c9523aa | ||
|
|
94e0071883 | ||
|
|
2f71771b56 | ||
|
|
d7ddf2023d | ||
|
|
c58408d76c | ||
|
|
1188cfef1d | ||
|
|
fb0e7fecaf | ||
|
|
3afbb48188 | ||
|
|
837f41986d | ||
|
|
8eda626466 | ||
|
|
fe9818226d | ||
|
|
1e8438a478 | ||
|
|
8fdabc44f5 | ||
|
|
e9e9245112 | ||
|
|
580be2cf32 | ||
|
|
911bb9dcda | ||
|
|
380e70060b | ||
|
|
507cf93687 | ||
|
|
ba6e9cc90f | ||
|
|
228ac0d568 | ||
|
|
c6ecaf9642 | ||
|
|
534d2191ff | ||
|
|
709fd52b0b | ||
|
|
c5d795c1f1 | ||
|
|
983f2818b0 | ||
|
|
b4eda37fbf | ||
|
|
a5fe47ee71 | ||
|
|
dc423b22b3 | ||
|
|
7c7ab88a60 | ||
|
|
21189ae130 |
@@ -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.
|
||||
|
||||
@@ -72,11 +72,11 @@
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.40",
|
||||
"antd": "^6.4.3",
|
||||
"baseline-browser-mapping": "^2.10.32",
|
||||
"baseline-browser-mapping": "^2.10.33",
|
||||
"caniuse-lite": "^1.0.30001793",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"js-yaml": "^4.2.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"json-bigint": "^1.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
|
||||
@@ -291,6 +291,12 @@ a > span > svg {
|
||||
.footer__social-links img {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
/* The brand SVGs ship in their native colors (e.g. Slack's dark aubergine,
|
||||
X's near-black), which disappear on the dark footer. Render them all as
|
||||
uniform white silhouettes. The icons are single-path glyphs whose
|
||||
counters (the LinkedIn "in", Slack gaps, Reddit face) are transparent
|
||||
cut-outs, so they stay legible against the footer background. */
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.footer__ci-services {
|
||||
|
||||
@@ -5578,10 +5578,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.32, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.32"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz#b6b553a4285fdd606327a617de36a5351e3aaa64"
|
||||
integrity sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==
|
||||
baseline-browser-mapping@^2.10.33, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.33"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz#27c299b096404978831958d429f48390424c4f9b"
|
||||
integrity sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -9341,7 +9341,7 @@ js-yaml@4.1.0:
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
js-yaml@=4.1.1, js-yaml@^4.1.0, js-yaml@^4.1.1:
|
||||
js-yaml@=4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
|
||||
integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
|
||||
@@ -9356,6 +9356,13 @@ js-yaml@^3.13.1:
|
||||
argparse "^1.0.7"
|
||||
esprima "^4.0.0"
|
||||
|
||||
js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.2.0.tgz#2bd9e85682dd91bd469afb809d816043b3d49524"
|
||||
integrity sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
jsdoc-type-pratt-parser@^4.0.0:
|
||||
version "4.8.0"
|
||||
resolved "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.8.0.tgz"
|
||||
|
||||
@@ -69,10 +69,6 @@ class BaseExtension(BaseModel):
|
||||
default=None,
|
||||
description="Extension description",
|
||||
)
|
||||
dependencies: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of extension IDs this extension depends on",
|
||||
)
|
||||
permissions: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Permissions required by this extension",
|
||||
|
||||
@@ -29,8 +29,8 @@ Embedding is done by inserting an iframe, containing a Superset page, into the h
|
||||
|
||||
## Prerequisites
|
||||
|
||||
* Activate the feature flag `EMBEDDED_SUPERSET`
|
||||
* Set a strong password in configuration variable `GUEST_TOKEN_JWT_SECRET` (see configuration file config.py). Be aware that its default value must be changed in production.
|
||||
- Activate the feature flag `EMBEDDED_SUPERSET`
|
||||
- Set a strong password in configuration variable `GUEST_TOKEN_JWT_SECRET` (see configuration file config.py). Be aware that its default value must be changed in production.
|
||||
|
||||
## Embedding a Dashboard
|
||||
|
||||
@@ -41,32 +41,37 @@ npm install --save @superset-ui/embedded-sdk
|
||||
```
|
||||
|
||||
```js
|
||||
import { embedDashboard } from "@superset-ui/embedded-sdk";
|
||||
import { embedDashboard } from '@superset-ui/embedded-sdk';
|
||||
|
||||
embedDashboard({
|
||||
id: "abc123", // given by the Superset embedding UI
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint: document.getElementById("my-superset-container"), // any html element that can contain an iframe
|
||||
id: 'abc123', // given by the Superset embedding UI
|
||||
supersetDomain: 'https://superset.example.com',
|
||||
mountPoint: document.getElementById('my-superset-container'), // any html element that can contain an iframe
|
||||
fetchGuestToken: () => fetchGuestTokenFromBackend(),
|
||||
dashboardUiConfig: { // dashboard UI config: hideTitle, hideTab, hideChartControls, filters.visible, filters.expanded (optional), urlParams (optional)
|
||||
hideTitle: true,
|
||||
filters: {
|
||||
expanded: true,
|
||||
},
|
||||
urlParams: {
|
||||
foo: 'value1',
|
||||
bar: 'value2',
|
||||
// ...
|
||||
}
|
||||
dashboardUiConfig: {
|
||||
// dashboard UI config: hideTitle, hideTab, hideChartControls, filters.visible, filters.expanded (optional), urlParams (optional)
|
||||
hideTitle: true,
|
||||
filters: {
|
||||
expanded: true,
|
||||
},
|
||||
urlParams: {
|
||||
foo: 'value1',
|
||||
bar: 'value2',
|
||||
// themeMode: 'dark', // set the initial theme: 'dark' | 'system' | 'default' (default: 'default')
|
||||
// ...
|
||||
},
|
||||
},
|
||||
// optional additional iframe sandbox attributes
|
||||
iframeSandboxExtras: ['allow-top-navigation', 'allow-popups-to-escape-sandbox'],
|
||||
iframeSandboxExtras: [
|
||||
'allow-top-navigation',
|
||||
'allow-popups-to-escape-sandbox',
|
||||
],
|
||||
// optional Permissions Policy features
|
||||
iframeAllowExtras: ['clipboard-write', 'fullscreen'],
|
||||
// optional config to enforce a particular referrerPolicy
|
||||
referrerPolicy: "same-origin",
|
||||
referrerPolicy: 'same-origin',
|
||||
// optional callback to customize permalink URLs
|
||||
resolvePermalinkUrl: ({ key }) => `https://my-app.com/analytics/share/${key}`
|
||||
resolvePermalinkUrl: ({ key }) => `https://my-app.com/analytics/share/${key}`,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -97,7 +102,7 @@ Guest tokens can have Row Level Security rules which filter data for the user ca
|
||||
|
||||
The agent making the `POST` request must be authenticated with the `can_grant_guest_token` permission.
|
||||
|
||||
Within your app, using the Guest Token will then allow authentication to your Superset instance via creating an Anonymous user object. This guest anonymous user will default to the public role as per this setting `GUEST_ROLE_NAME = "Public"`.
|
||||
Within your app, using the Guest Token will then allow authentication to your Superset instance via creating an Anonymous user object. This guest anonymous user will default to the public role as per this setting `GUEST_ROLE_NAME = "Public"`.
|
||||
|
||||
The user parameters in the example below are optional and are provided as a means of passing user attributes that may be accessed in jinja templates inside your charts.
|
||||
|
||||
@@ -110,13 +115,13 @@ Example `POST /security/guest_token` payload:
|
||||
"first_name": "Stan",
|
||||
"last_name": "Lee"
|
||||
},
|
||||
"resources": [{
|
||||
"type": "dashboard",
|
||||
"id": "abc123"
|
||||
}],
|
||||
"rls": [
|
||||
{ "clause": "publisher = 'Nintendo'" }
|
||||
]
|
||||
"resources": [
|
||||
{
|
||||
"type": "dashboard",
|
||||
"id": "abc123"
|
||||
}
|
||||
],
|
||||
"rls": [{ "clause": "publisher = 'Nintendo'" }]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -152,15 +157,43 @@ In this example, the configuration file includes the following setting:
|
||||
GUEST_TOKEN_JWT_AUDIENCE="superset"
|
||||
```
|
||||
|
||||
### Setting the Initial Theme Mode
|
||||
|
||||
Use the `themeMode` URL parameter to control the embedded dashboard's initial colour scheme:
|
||||
|
||||
```js
|
||||
embedDashboard({
|
||||
id: 'abc123',
|
||||
supersetDomain: 'https://superset.example.com',
|
||||
mountPoint: document.getElementById('my-superset-container'),
|
||||
fetchGuestToken: () => fetchGuestTokenFromBackend(),
|
||||
dashboardUiConfig: {
|
||||
urlParams: {
|
||||
themeMode: 'dark', // 'dark' | 'system' | 'default' (default: 'default')
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The supported values are:
|
||||
|
||||
| Value | Behaviour |
|
||||
| --------- | --------------------------------------------------------- |
|
||||
| `default` | Light theme (Superset default) |
|
||||
| `dark` | Dark theme |
|
||||
| `system` | Follows the user's OS preference (`prefers-color-scheme`) |
|
||||
|
||||
The theme can also be changed at runtime via `embeddedDashboard.setThemeMode(mode)`.
|
||||
|
||||
### Sandbox iframe
|
||||
|
||||
The Embedded SDK creates an iframe with [sandbox](https://developer.mozilla.org/es/docs/Web/HTML/Element/iframe#sandbox) mode by default
|
||||
which applies certain restrictions to the iframe's content.
|
||||
To pass additional sandbox attributes you can use `iframeSandboxExtras`:
|
||||
|
||||
```js
|
||||
// optional additional iframe sandbox attributes
|
||||
iframeSandboxExtras: ['allow-top-navigation', 'allow-popups-to-escape-sandbox']
|
||||
// optional additional iframe sandbox attributes
|
||||
iframeSandboxExtras: ['allow-top-navigation', 'allow-popups-to-escape-sandbox'];
|
||||
```
|
||||
|
||||
### Permissions Policy
|
||||
@@ -168,11 +201,12 @@ To pass additional sandbox attributes you can use `iframeSandboxExtras`:
|
||||
To enable specific browser features within the embedded iframe, use `iframeAllowExtras` to set the iframe's [Permissions Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Permissions_Policy) (the `allow` attribute):
|
||||
|
||||
```js
|
||||
// optional Permissions Policy features
|
||||
iframeAllowExtras: ['clipboard-write', 'fullscreen']
|
||||
// optional Permissions Policy features
|
||||
iframeAllowExtras: ['clipboard-write', 'fullscreen'];
|
||||
```
|
||||
|
||||
Common permissions you might need:
|
||||
|
||||
- `clipboard-write` - Required for "Copy permalink to clipboard" functionality
|
||||
- `fullscreen` - Required for fullscreen chart viewing
|
||||
- `camera`, `microphone` - If your dashboards include media capture features
|
||||
@@ -191,16 +225,16 @@ When users click share buttons inside an embedded dashboard, Superset generates
|
||||
|
||||
```js
|
||||
embedDashboard({
|
||||
id: "abc123",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint: document.getElementById("my-superset-container"),
|
||||
id: 'abc123',
|
||||
supersetDomain: 'https://superset.example.com',
|
||||
mountPoint: document.getElementById('my-superset-container'),
|
||||
fetchGuestToken: () => fetchGuestTokenFromBackend(),
|
||||
|
||||
// Customize permalink URLs
|
||||
resolvePermalinkUrl: ({ key }) => {
|
||||
// key: the permalink key (e.g., "xyz789")
|
||||
return `https://my-app.com/analytics/share/${key}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -211,15 +245,15 @@ To restore the dashboard state from a permalink in your app:
|
||||
const permalinkKey = routeParams.key;
|
||||
|
||||
embedDashboard({
|
||||
id: "abc123",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint: document.getElementById("my-superset-container"),
|
||||
id: 'abc123',
|
||||
supersetDomain: 'https://superset.example.com',
|
||||
mountPoint: document.getElementById('my-superset-container'),
|
||||
fetchGuestToken: () => fetchGuestTokenFromBackend(),
|
||||
resolvePermalinkUrl: ({ key }) => `https://my-app.com/analytics/share/${key}`,
|
||||
dashboardUiConfig: {
|
||||
urlParams: {
|
||||
permalink_key: permalinkKey, // Restores filters, tabs, chart states, and scrolls to anchor
|
||||
}
|
||||
}
|
||||
permalink_key: permalinkKey, // Restores filters, tabs, chart states, and scrolls to anchor
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -179,7 +179,6 @@ def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest:
|
||||
displayName=extension.displayName,
|
||||
version=extension.version,
|
||||
permissions=extension.permissions,
|
||||
dependencies=extension.dependencies,
|
||||
frontend=frontend,
|
||||
backend=backend,
|
||||
)
|
||||
@@ -226,7 +225,7 @@ def copy_frontend_dist(cwd: Path) -> str:
|
||||
def copy_backend_files(cwd: Path) -> None:
|
||||
"""Copy backend files based on pyproject.toml build configuration (validation already passed)."""
|
||||
dist_dir = cwd / "dist"
|
||||
backend_dir = cwd / "backend"
|
||||
backend_dir = (cwd / "backend").resolve()
|
||||
|
||||
# Read build config from pyproject.toml
|
||||
pyproject = read_toml(backend_dir / "pyproject.toml")
|
||||
@@ -239,11 +238,31 @@ def copy_backend_files(cwd: Path) -> None:
|
||||
|
||||
# Process include patterns
|
||||
for pattern in include_patterns:
|
||||
# Include patterns are only meant to select files within the backend
|
||||
# directory. Reject absolute patterns or ones that walk outside it via
|
||||
# parent ("..") components before handing them to glob().
|
||||
pattern_parts = Path(pattern).parts
|
||||
if Path(pattern).is_absolute() or ".." in pattern_parts:
|
||||
raise click.ClickException(
|
||||
f"Invalid include pattern {pattern!r}: patterns must be "
|
||||
"relative to the backend directory and may not contain '..'."
|
||||
)
|
||||
for f in backend_dir.glob(pattern):
|
||||
if not f.is_file():
|
||||
continue
|
||||
|
||||
# Check exclude patterns
|
||||
# Defense in depth: confirm the matched file resolves to a location
|
||||
# inside the backend directory before copying it into the bundle.
|
||||
resolved = f.resolve()
|
||||
if not resolved.is_relative_to(backend_dir):
|
||||
raise click.ClickException(
|
||||
f"Refusing to copy {f}: resolved path is outside the "
|
||||
f"backend directory {backend_dir}."
|
||||
)
|
||||
|
||||
# Use the matched path (not the resolved target) for the bundle
|
||||
# layout and exclude evaluation so symlinked files are staged at
|
||||
# their configured path rather than their symlink target.
|
||||
relative_path = f.relative_to(backend_dir)
|
||||
should_exclude = any(
|
||||
relative_path.match(excl_pattern) for excl_pattern in exclude_patterns
|
||||
|
||||
@@ -20,6 +20,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from superset_extensions_cli.cli import (
|
||||
app,
|
||||
@@ -282,7 +283,6 @@ def test_build_manifest_creates_correct_manifest_structure(
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": ["read_data"],
|
||||
"dependencies": ["some_dep"],
|
||||
}
|
||||
extension_json = isolated_filesystem / "extension.json"
|
||||
extension_json.write_text(json.dumps(extension_data))
|
||||
@@ -296,7 +296,6 @@ def test_build_manifest_creates_correct_manifest_structure(
|
||||
assert manifest.displayName == "Test Extension"
|
||||
assert manifest.version == "1.0.0"
|
||||
assert manifest.permissions == ["read_data"]
|
||||
assert manifest.dependencies == ["some_dep"]
|
||||
|
||||
# Verify frontend section
|
||||
assert manifest.frontend is not None
|
||||
@@ -329,7 +328,6 @@ def test_build_manifest_handles_minimal_extension(isolated_filesystem):
|
||||
assert manifest.displayName == "Minimal Extension"
|
||||
assert manifest.version == "0.1.0"
|
||||
assert manifest.permissions == []
|
||||
assert manifest.dependencies == [] # Default empty list
|
||||
assert manifest.frontend is None
|
||||
assert manifest.backend is None
|
||||
|
||||
@@ -625,6 +623,155 @@ exclude = []
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_copy_backend_files_supports_legitimate_nested_patterns(isolated_filesystem):
|
||||
"""Test copy_backend_files copies deeply nested files via recursive globs."""
|
||||
backend_dir = isolated_filesystem / "backend"
|
||||
nested = backend_dir / "src" / "test_org" / "test_ext" / "deep" / "deeper"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "module.py").write_text("# nested module")
|
||||
|
||||
pyproject_content = """[project]
|
||||
name = "test_org-test_ext"
|
||||
version = "1.0.0"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[tool.apache_superset_extensions.build]
|
||||
include = [
|
||||
"src/test_org/test_ext/**/*.py",
|
||||
]
|
||||
exclude = []
|
||||
"""
|
||||
(backend_dir / "pyproject.toml").write_text(pyproject_content)
|
||||
|
||||
extension_data = {
|
||||
"publisher": "test-org",
|
||||
"name": "test-ext",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
|
||||
|
||||
clean_dist(isolated_filesystem)
|
||||
copy_backend_files(isolated_filesystem)
|
||||
|
||||
dist_dir = isolated_filesystem / "dist"
|
||||
assert_file_exists(
|
||||
dist_dir
|
||||
/ "backend"
|
||||
/ "src"
|
||||
/ "test_org"
|
||||
/ "test_ext"
|
||||
/ "deep"
|
||||
/ "deeper"
|
||||
/ "module.py"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"bad_pattern",
|
||||
[
|
||||
"../../.ssh/*",
|
||||
"../config",
|
||||
"src/../../secret.txt",
|
||||
"/etc/passwd",
|
||||
],
|
||||
)
|
||||
def test_copy_backend_files_rejects_patterns_escaping_backend_dir(
|
||||
isolated_filesystem, bad_pattern
|
||||
):
|
||||
"""Test copy_backend_files refuses include patterns that escape backend_dir."""
|
||||
# Create a sensitive file outside the backend directory.
|
||||
(isolated_filesystem / "secret.txt").write_text("SECRET")
|
||||
(isolated_filesystem / "config").write_text("SECRET")
|
||||
|
||||
backend_dir = isolated_filesystem / "backend"
|
||||
backend_src = backend_dir / "src" / "test_org" / "test_ext"
|
||||
backend_src.mkdir(parents=True)
|
||||
(backend_src / "__init__.py").write_text("# init")
|
||||
|
||||
pyproject_content = f"""[project]
|
||||
name = "test_org-test_ext"
|
||||
version = "1.0.0"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[tool.apache_superset_extensions.build]
|
||||
include = [
|
||||
"{bad_pattern}",
|
||||
]
|
||||
exclude = []
|
||||
"""
|
||||
(backend_dir / "pyproject.toml").write_text(pyproject_content)
|
||||
|
||||
extension_data = {
|
||||
"publisher": "test-org",
|
||||
"name": "test-ext",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
|
||||
|
||||
clean_dist(isolated_filesystem)
|
||||
|
||||
with pytest.raises(click.ClickException):
|
||||
copy_backend_files(isolated_filesystem)
|
||||
|
||||
# Nothing outside the backend directory should have been staged into dist,
|
||||
# including paths reachable via ".." from inside dist/backend.
|
||||
dist_dir = isolated_filesystem / "dist"
|
||||
assert not (dist_dir / "secret.txt").exists()
|
||||
assert not (dist_dir / "config").exists()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_copy_backend_files_stages_symlink_at_matched_path(isolated_filesystem):
|
||||
"""Symlinked files inside backend are staged at the matched path, not the target."""
|
||||
backend_dir = isolated_filesystem / "backend"
|
||||
target_dir = backend_dir / "src" / "common"
|
||||
target_dir.mkdir(parents=True)
|
||||
(target_dir / "module.py").write_text("# shared module")
|
||||
|
||||
link_dir = backend_dir / "src" / "test_org" / "test_ext" / "common"
|
||||
link_dir.mkdir(parents=True)
|
||||
link = link_dir / "module.py"
|
||||
link.symlink_to(target_dir / "module.py")
|
||||
|
||||
pyproject_content = """[project]
|
||||
name = "test_org-test_ext"
|
||||
version = "1.0.0"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[tool.apache_superset_extensions.build]
|
||||
include = [
|
||||
"src/test_org/test_ext/**/*.py",
|
||||
]
|
||||
exclude = []
|
||||
"""
|
||||
(backend_dir / "pyproject.toml").write_text(pyproject_content)
|
||||
|
||||
extension_data = {
|
||||
"publisher": "test-org",
|
||||
"name": "test-ext",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
|
||||
|
||||
clean_dist(isolated_filesystem)
|
||||
copy_backend_files(isolated_filesystem)
|
||||
|
||||
dist_dir = isolated_filesystem / "dist"
|
||||
# Staged at the configured (symlink) path, not the resolved target path.
|
||||
assert_file_exists(
|
||||
dist_dir / "backend" / "src" / "test_org" / "test_ext" / "common" / "module.py"
|
||||
)
|
||||
assert not (dist_dir / "backend" / "src" / "common" / "module.py").exists()
|
||||
|
||||
|
||||
# Removed obsolete tests:
|
||||
# - test_copy_backend_files_handles_no_backend_config: This scenario can't happen since copy_backend_files is only called when backend exists
|
||||
# - test_copy_backend_files_exits_when_extension_json_missing: Validation catches this before copy_backend_files is called
|
||||
|
||||
@@ -1,67 +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 { SAMPLE_DASHBOARD_1 } from 'cypress/utils/urls';
|
||||
import { interceptFav, interceptUnfav } from './utils';
|
||||
|
||||
describe('Dashboard actions', () => {
|
||||
beforeEach(() => {
|
||||
cy.createSampleDashboards([0]);
|
||||
cy.visit(SAMPLE_DASHBOARD_1);
|
||||
});
|
||||
it('should allow to favorite/unfavorite dashboard', () => {
|
||||
interceptFav();
|
||||
interceptUnfav();
|
||||
|
||||
// Find and click StarOutlined (adds to favorites)
|
||||
cy.getBySel('dashboard-header-container')
|
||||
.find("[aria-label='unstarred']")
|
||||
.as('starIconOutlined')
|
||||
.should('exist')
|
||||
.click();
|
||||
|
||||
cy.wait('@select');
|
||||
|
||||
// After clicking, StarFilled should appear
|
||||
cy.getBySel('dashboard-header-container')
|
||||
.find("[aria-label='starred']")
|
||||
.as('starIconFilled')
|
||||
.should('exist');
|
||||
|
||||
// Verify the color of the filled star (gold)
|
||||
cy.get('@starIconFilled')
|
||||
.should('have.css', 'color')
|
||||
.and('eq', 'rgb(252, 199, 0)');
|
||||
|
||||
// Click on StarFilled (removes from favorites)
|
||||
cy.get('@starIconFilled').click();
|
||||
|
||||
cy.wait('@unselect');
|
||||
|
||||
// After clicking, StarOutlined should reappear
|
||||
cy.getBySel('dashboard-header-container')
|
||||
.find("[aria-label='unstarred']")
|
||||
.as('starIconOutlinedAfter')
|
||||
.should('exist');
|
||||
|
||||
// Verify the color of the outlined star (gray)
|
||||
cy.get('@starIconOutlinedAfter')
|
||||
.should('have.css', 'color')
|
||||
.and('eq', 'rgba(0, 0, 0, 0.45)');
|
||||
});
|
||||
});
|
||||
@@ -160,18 +160,6 @@ export function interceptLog() {
|
||||
cy.intercept('**/superset/log/?explode=events&dashboard_id=*').as('logs');
|
||||
}
|
||||
|
||||
export function interceptFav() {
|
||||
cy.intercept({ url: `**/api/v1/dashboard/*/favorites/`, method: 'POST' }).as(
|
||||
'select',
|
||||
);
|
||||
}
|
||||
|
||||
export function interceptUnfav() {
|
||||
cy.intercept({ url: `**/api/v1/dashboard/*/favorites/`, method: 'POST' }).as(
|
||||
'unselect',
|
||||
);
|
||||
}
|
||||
|
||||
export function interceptDataset() {
|
||||
cy.intercept('GET', `**/api/v1/dataset/*`).as('getDataset');
|
||||
}
|
||||
|
||||
41
superset-frontend/package-lock.json
generated
41
superset-frontend/package-lock.json
generated
@@ -196,7 +196,7 @@
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.40",
|
||||
"@swc/plugin-emotion": "^14.10.0",
|
||||
"@swc/plugin-emotion": "^14.12.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -230,7 +230,7 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.32",
|
||||
"baseline-browser-mapping": "^2.10.33",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.0",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -287,7 +287,7 @@
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.3",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
@@ -12612,9 +12612,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/plugin-emotion": {
|
||||
"version": "14.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.10.0.tgz",
|
||||
"integrity": "sha512-uhPq0oJHk2/W2Hn6vLaNmbUUgNPPj0FINHISxfs9hqS2Hpv/TVzQFsnbxul1FJEa+YQe1Qebou2esDphwzIuKg==",
|
||||
"version": "14.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.12.0.tgz",
|
||||
"integrity": "sha512-lyAQgTeDkowq/4+8JYaviVOL4jXSdObz+uuk84DjM0z4qoiMpI6xoDVp7/tjWeVjmLc2U6Qp3hDuwWMZ5xe88Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -17253,9 +17253,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.32",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
|
||||
"integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==",
|
||||
"version": "2.10.33",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
|
||||
"integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -40527,6 +40527,7 @@
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
|
||||
"integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
@@ -45406,9 +45407,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.22.3",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
|
||||
"integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -49348,7 +49349,7 @@
|
||||
"parse-ms": "^4.0.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.5.0",
|
||||
"react-draggable": "^4.6.0",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
@@ -49475,6 +49476,20 @@
|
||||
"react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-draggable": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.6.0.tgz",
|
||||
"integrity": "sha512-g4vqY53xhmPrBnZvGP+1YQV0eYnB3o0VLzoi6q2IpwnQrxIZ34tYRKpVtsWIXPg4D/pvLn+oYCW5gOK2cWIrgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3.0",
|
||||
"react-dom": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-error-boundary": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.0.0.tgz",
|
||||
|
||||
@@ -279,7 +279,7 @@
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.40",
|
||||
"@swc/plugin-emotion": "^14.10.0",
|
||||
"@swc/plugin-emotion": "^14.12.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -313,7 +313,7 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.32",
|
||||
"baseline-browser-mapping": "^2.10.33",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.0",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -370,7 +370,7 @@
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.3",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
"types": "./lib/authentication/index.d.ts",
|
||||
"default": "./lib/authentication/index.js"
|
||||
},
|
||||
"./dashboard": {
|
||||
"types": "./lib/dashboard/index.d.ts",
|
||||
"default": "./lib/dashboard/index.js"
|
||||
},
|
||||
"./dataset": {
|
||||
"types": "./lib/dataset/index.d.ts",
|
||||
"default": "./lib/dataset/index.js"
|
||||
},
|
||||
"./explore": {
|
||||
"types": "./lib/explore/index.d.ts",
|
||||
"default": "./lib/explore/index.js"
|
||||
},
|
||||
"./navigation": {
|
||||
"types": "./lib/navigation/index.d.ts",
|
||||
"default": "./lib/navigation/index.js"
|
||||
},
|
||||
"./commands": {
|
||||
"types": "./lib/commands/index.d.ts",
|
||||
"default": "./lib/commands/index.js"
|
||||
|
||||
@@ -213,18 +213,55 @@ export declare interface Event<T> {
|
||||
(listener: (e: T) => any, thisArgs?: any): Disposable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context handed to an extension's `activate` function.
|
||||
*
|
||||
* `context.subscriptions` is provided for extensions to push their
|
||||
* {@link Disposable}s into. The host provides the array but does not dispose
|
||||
* it (lifecycle management is deferred).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* export function activate(context: ExtensionContext) {
|
||||
* context.subscriptions.push(
|
||||
* commands.registerCommand('my_ext.hello', () => {}),
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface ExtensionContext {
|
||||
/**
|
||||
* Disposables pushed by the extension. Provided for extensions to track
|
||||
* their own registrations; the host does not dispose them.
|
||||
*/
|
||||
subscriptions: { dispose(): void }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of an extension's entry module (its `./index`).
|
||||
*
|
||||
* Extensions are encouraged to export an `activate(context)` function so that
|
||||
* their registrations are tracked via `context.subscriptions` regardless of
|
||||
* whether they run synchronously or asynchronously. For backward compatibility,
|
||||
* a module may instead register its contributions as top-level side effects when
|
||||
* the module is evaluated.
|
||||
*/
|
||||
export interface ExtensionModule {
|
||||
/**
|
||||
* Called by the host once the extension module has loaded. May be async; the
|
||||
* host awaits it before considering the extension active.
|
||||
*/
|
||||
activate?(context: ExtensionContext): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a Superset extension with its metadata.
|
||||
* Extensions are modular components that can extend Superset's functionality
|
||||
* by registering commands, views, menus, and editors as module-level side effects.
|
||||
*/
|
||||
export interface Extension {
|
||||
/** List of other extensions that this extension depends on */
|
||||
dependencies: string[];
|
||||
/** Human-readable description of the extension */
|
||||
description: string;
|
||||
/** List of other extensions that this extension depends on */
|
||||
extensionDependencies: string[];
|
||||
/** Unique identifier for the extension */
|
||||
id: string;
|
||||
/** Human-readable name of the extension */
|
||||
|
||||
@@ -43,6 +43,9 @@ export type SqlLabLocation =
|
||||
| 'results'
|
||||
| 'queryHistory';
|
||||
|
||||
/** Valid locations within the app shell (persist across all routes). */
|
||||
export type AppLocation = 'chatbot';
|
||||
|
||||
/**
|
||||
* Nested structure for view contributions by scope and location.
|
||||
* @example
|
||||
@@ -55,6 +58,7 @@ export type SqlLabLocation =
|
||||
*/
|
||||
export interface ViewContributions {
|
||||
sqllab?: Partial<Record<SqlLabLocation, View[]>>;
|
||||
app?: Partial<Record<AppLocation, View[]>>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
114
superset-frontend/packages/superset-core/src/dashboard/index.ts
Normal file
114
superset-frontend/packages/superset-core/src/dashboard/index.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Dashboard namespace for Superset extensions (P3).
|
||||
*
|
||||
* Exposes dashboard identity and filter state as a stable semantic API.
|
||||
* Extensions must not depend on the Redux dashboard slice structure directly.
|
||||
*/
|
||||
|
||||
import { Event } from '../common';
|
||||
|
||||
/**
|
||||
* A single native filter's current selected value(s).
|
||||
* The value type is intentionally kept as `unknown` because filter values
|
||||
* are heterogeneous (date ranges, string lists, numbers, etc.).
|
||||
*/
|
||||
export interface FilterValue {
|
||||
/** The filter's stable id. */
|
||||
filterId: string;
|
||||
/** Display label of the filter. */
|
||||
label: string;
|
||||
/** Currently applied value, or `null` when the filter is cleared. */
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary of a single chart on the active dashboard.
|
||||
*
|
||||
* Exposes the identity, viz type, datasource, and current visibility of a
|
||||
* chart so extensions can answer both "which charts are visible?" and
|
||||
* "find the chart named X" without additional lookups.
|
||||
*/
|
||||
export interface ChartSummary {
|
||||
/** Numeric chart (slice) id. */
|
||||
chartId: number;
|
||||
/** Display name of the chart. */
|
||||
chartName: string;
|
||||
/** Visualization type key (e.g. `'echarts_timeseries_bar'`). */
|
||||
vizType: string;
|
||||
/** Datasource id, or `null` when not resolvable. */
|
||||
datasourceId: number | null;
|
||||
/** Datasource name, or `null` when not resolvable. */
|
||||
datasourceName: string | null;
|
||||
/** Whether the chart is currently visible (e.g. on the active tab). */
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized dashboard context exposed to extensions on the Dashboard page.
|
||||
*/
|
||||
export interface DashboardContext {
|
||||
/** Numeric dashboard id. */
|
||||
dashboardId: number;
|
||||
/** Display title of the dashboard. */
|
||||
title: string;
|
||||
/**
|
||||
* Active native filter values keyed by filter id.
|
||||
* Only includes filters that have a value applied.
|
||||
*/
|
||||
filters: FilterValue[];
|
||||
/**
|
||||
* Summaries of the dashboard's charts, including per-chart visibility.
|
||||
*
|
||||
* Optional: the contract is declared so extensions can compile against the
|
||||
* stable shape, but population is delivered in a later phase (see
|
||||
* CHATBOT_SIP.md §10/§11). The host returns an empty array until then.
|
||||
*/
|
||||
charts?: ChartSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized dashboard context for the page currently being viewed,
|
||||
* or `undefined` when the user is not on a Dashboard page.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const dash = dashboard.getCurrentDashboard();
|
||||
* if (dash) {
|
||||
* console.log(dash.title, dash.filters);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function getCurrentDashboard(): DashboardContext | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when the dashboard identity or its active filter values change.
|
||||
* Fired on native filter value changes and on navigation to a different dashboard.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sub = dashboard.onDidChangeDashboard(dash => {
|
||||
* chatbot.updateContext({ dashboard: dash });
|
||||
* });
|
||||
* sub.dispose();
|
||||
* ```
|
||||
*/
|
||||
export declare const onDidChangeDashboard: Event<DashboardContext>;
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Dataset namespace for Superset extensions (P3).
|
||||
*
|
||||
* Exposes the dataset currently being viewed as a stable semantic API.
|
||||
* Aligned with backend-enforced dataset visibility and column-access semantics.
|
||||
*/
|
||||
|
||||
import { Event } from '../common';
|
||||
|
||||
/**
|
||||
* Normalized dataset context exposed to extensions on the Dataset page.
|
||||
*/
|
||||
export interface DatasetContext {
|
||||
/** Numeric dataset id. */
|
||||
datasetId: number;
|
||||
/** Display name (table name or virtual dataset name). */
|
||||
datasetName: string;
|
||||
/** Schema the dataset belongs to, if applicable. */
|
||||
schema: string | null;
|
||||
/** Catalog the dataset belongs to, if applicable. */
|
||||
catalog: string | null;
|
||||
/** Database name backing this dataset. */
|
||||
databaseName: string | null;
|
||||
/** Whether this is a virtual (SQL-defined) dataset. */
|
||||
isVirtual: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized dataset context for the page currently being viewed,
|
||||
* or `undefined` when the user is not on a Dataset page.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const ds = dataset.getCurrentDataset();
|
||||
* if (ds) {
|
||||
* console.log(ds.datasetName, ds.schema);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function getCurrentDataset(): DatasetContext | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when the focused dataset changes (e.g. the user navigates to a
|
||||
* different dataset detail page).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sub = dataset.onDidChangeDataset(ds => {
|
||||
* chatbot.updateContext({ dataset: ds });
|
||||
* });
|
||||
* sub.dispose();
|
||||
* ```
|
||||
*/
|
||||
export declare const onDidChangeDataset: Event<DatasetContext>;
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Explore namespace for Superset extensions (P3).
|
||||
*
|
||||
* Exposes the current chart/explore context as a stable semantic API.
|
||||
* Normalized over Explore Redux state — extensions must not depend on
|
||||
* the Redux slice structure directly.
|
||||
*/
|
||||
|
||||
import { Event } from '../common';
|
||||
|
||||
/**
|
||||
* Normalized chart context exposed to extensions during an Explore session.
|
||||
* Covers saved chart identity and transient editing context; excludes raw
|
||||
* form-data internals and datasource-implementation details.
|
||||
*/
|
||||
export interface ChartContext {
|
||||
/** The saved chart id, or `null` when the chart has not been persisted. */
|
||||
chartId: number | null;
|
||||
/** Display name of the saved chart, or `null` for a new/unsaved chart. */
|
||||
chartName: string | null;
|
||||
/** The visualization type currently selected in the editor. */
|
||||
vizType: string;
|
||||
/** Id of the datasource backing the chart (physical or virtual dataset). */
|
||||
datasourceId: number | null;
|
||||
/** Human-readable datasource name. */
|
||||
datasourceName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized chart context for the active Explore session, or
|
||||
* `undefined` when the user is not on the Explore page.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const chart = explore.getCurrentChart();
|
||||
* if (chart) {
|
||||
* console.log(chart.vizType, chart.chartName);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function getCurrentChart(): ChartContext | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when the chart context changes within the active Explore session
|
||||
* (e.g. when the viz type, datasource, or saved name changes).
|
||||
* Not fired during route changes — subscribe to `navigation.onDidChangePage` for those.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sub = explore.onDidChangeChart(chart => {
|
||||
* chatbot.updateContext({ chart });
|
||||
* });
|
||||
* sub.dispose();
|
||||
* ```
|
||||
*/
|
||||
export declare const onDidChangeChart: Event<ChartContext>;
|
||||
@@ -19,9 +19,13 @@
|
||||
export * as common from './common';
|
||||
export * as authentication from './authentication';
|
||||
export * as commands from './commands';
|
||||
export * as dashboard from './dashboard';
|
||||
export * as dataset from './dataset';
|
||||
export * as editors from './editors';
|
||||
export * as explore from './explore';
|
||||
export * as extensions from './extensions';
|
||||
export * as menus from './menus';
|
||||
export * as navigation from './navigation';
|
||||
export * as sqlLab from './sqlLab';
|
||||
export * as views from './views';
|
||||
export * as contributions from './contributions';
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Navigation namespace for Superset extensions (P3).
|
||||
*
|
||||
* Exposes the current application surface so extensions can react to route
|
||||
* changes without polling. Entity-level context (chart, dashboard, dataset)
|
||||
* is intentionally not included here — use the surface-specific namespace
|
||||
* (`explore`, `dashboard`, `dataset`) to retrieve entity payloads.
|
||||
*/
|
||||
|
||||
import { Event } from '../common';
|
||||
|
||||
/**
|
||||
* The set of top-level application surfaces.
|
||||
*
|
||||
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
|
||||
* editing/viewing surfaces where `explore.getCurrentChart()` /
|
||||
* `dashboard.getCurrentDashboard()` / `dataset.getCurrentDataset()` resolve to a
|
||||
* concrete entity. `'chart_list'`, `'dashboard_list'` and `'dataset_list'` are
|
||||
* the browse/list surfaces, distinct from those because no single entity is
|
||||
* active. `'sqllab'` is the SQL editor where `sqlLab.getCurrentTab()` resolves;
|
||||
* `'query_history'` and `'saved_queries'` are the related SQL Lab browse pages,
|
||||
* which are not the editor. `'other'` covers any route not explicitly enumerated.
|
||||
*/
|
||||
export type PageType =
|
||||
| 'dashboard'
|
||||
| 'dashboard_list'
|
||||
| 'explore'
|
||||
| 'chart_list'
|
||||
| 'sqllab'
|
||||
| 'query_history'
|
||||
| 'saved_queries'
|
||||
| 'dataset'
|
||||
| 'dataset_list'
|
||||
| 'home'
|
||||
| 'other';
|
||||
|
||||
/**
|
||||
* Returns the current page surface type.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const pageType = navigation.getPageType();
|
||||
* if (pageType === 'dashboard') {
|
||||
* const ctx = dashboard.getCurrentDashboard();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function getPageType(): PageType;
|
||||
|
||||
/**
|
||||
* Event fired whenever the user navigates to a different surface.
|
||||
* Use the surface-specific namespace to read entity context after the event.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sub = navigation.onDidChangePage(pageType => {
|
||||
* if (pageType === 'dashboard') {
|
||||
* const ctx = dashboard.getCurrentDashboard();
|
||||
* }
|
||||
* });
|
||||
* // later:
|
||||
* sub.dispose();
|
||||
* ```
|
||||
*/
|
||||
export declare const onDidChangePage: Event<PageType>;
|
||||
@@ -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' },
|
||||
@@ -69,6 +75,15 @@ export interface View {
|
||||
* () => <ResultStatsPanel />,
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @example Chatbot bubble (`superset.chatbot` — singleton, host renders one)
|
||||
* ```typescript
|
||||
* views.registerView(
|
||||
* { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' },
|
||||
* 'superset.chatbot',
|
||||
* () => <ChatbotApp />,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export declare function registerView(
|
||||
view: View,
|
||||
|
||||
@@ -52,12 +52,12 @@
|
||||
"parse-ms": "^4.0.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.5.0",
|
||||
"react-draggable": "^4.6.0",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
|
||||
@@ -16,21 +16,35 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { isValidElement, cloneElement, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
isValidElement,
|
||||
cloneElement,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from 'react';
|
||||
import { isNil } from 'lodash';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { Modal as AntdModal, ModalProps as AntdModalProps } from 'antd';
|
||||
import { Resizable } from 're-resizable';
|
||||
import Draggable, {
|
||||
import RawDraggable, {
|
||||
DraggableBounds,
|
||||
DraggableData,
|
||||
DraggableEvent,
|
||||
DraggableProps,
|
||||
} from 'react-draggable';
|
||||
import { Icons } from '../Icons';
|
||||
import { Button } from '../Button';
|
||||
import type { ModalProps, StyledModalProps } from './types';
|
||||
|
||||
// react-draggable 4.6.0 ships generated types that mark every Draggable prop as
|
||||
// required (its LibraryManagedAttributes no longer honors defaultProps), even
|
||||
// though the component accepts a Partial<DraggableProps> at runtime. Re-type the
|
||||
// component so optional props stay optional, preserving the prior behavior.
|
||||
const Draggable = RawDraggable as ComponentType<Partial<DraggableProps>>;
|
||||
|
||||
const MODAL_HEADER_HEIGHT = 55;
|
||||
const MODAL_MIN_CONTENT_HEIGHT = 54;
|
||||
const MODAL_FOOTER_HEIGHT = 65;
|
||||
@@ -246,7 +260,7 @@ const CustomModal = ({
|
||||
[bodyStyle, stylesProp],
|
||||
);
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const [bounds, setBounds] = useState<DraggableBounds>();
|
||||
const [bounds, setBounds] = useState<DraggableBounds>({});
|
||||
const [dragDisabled, setDragDisabled] = useState<boolean>(true);
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -355,7 +369,7 @@ const CustomModal = ({
|
||||
resizable || draggable ? (
|
||||
<Draggable
|
||||
disabled={!draggable || dragDisabled}
|
||||
bounds={bounds}
|
||||
bounds={bounds ?? false}
|
||||
onStart={(event, uiData) => onDragStart(event, uiData)}
|
||||
{...draggableConfig}
|
||||
>
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface ModalProps {
|
||||
resizable?: boolean;
|
||||
resizableConfig?: ResizableProps;
|
||||
draggable?: boolean;
|
||||
draggableConfig?: DraggableProps;
|
||||
draggableConfig?: Partial<DraggableProps>;
|
||||
destroyOnHidden?: boolean;
|
||||
maskClosable?: boolean;
|
||||
zIndex?: number;
|
||||
|
||||
@@ -519,7 +519,8 @@ const Select = forwardRef(
|
||||
handleSelectAll();
|
||||
}}
|
||||
>
|
||||
{t('Select all')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
|
||||
{t('Select all')}{' '}
|
||||
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
@@ -536,7 +537,8 @@ const Select = forwardRef(
|
||||
handleDeselectAll();
|
||||
}}
|
||||
>
|
||||
{t('Clear')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
|
||||
{t('Clear')}{' '}
|
||||
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
|
||||
</Button>
|
||||
</StyledBulkActionsContainer>
|
||||
),
|
||||
|
||||
@@ -295,6 +295,7 @@ export function Table<RecordType extends object>(
|
||||
onRow,
|
||||
allowHTML = false,
|
||||
childrenColumnName,
|
||||
expandable: expandableProp,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
@@ -427,6 +428,7 @@ export function Table<RecordType extends object>(
|
||||
bordered,
|
||||
expandable: {
|
||||
childrenColumnName,
|
||||
...expandableProp,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import { Table, TableSize } from '@superset-ui/core/components/Table';
|
||||
import { TableRowSelection, SorterResult } from 'antd/es/table/interface';
|
||||
import { mapColumns, mapRows } from './utils';
|
||||
|
||||
interface TableCollectionProps<T extends object> {
|
||||
export interface TableCollectionProps<T extends object> {
|
||||
getTableProps: TablePropGetter<T>;
|
||||
getTableBodyProps: TableBodyPropGetter<T>;
|
||||
prepareRow: (row: Row<T>) => void;
|
||||
@@ -53,6 +53,7 @@ interface TableCollectionProps<T extends object> {
|
||||
onPageChange?: (page: number, pageSize: number) => void;
|
||||
isPaginationSticky?: boolean;
|
||||
showRowCount?: boolean;
|
||||
expandable?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const StyledTable = styled(Table)<{
|
||||
@@ -177,6 +178,7 @@ function TableCollection<T extends object>({
|
||||
onPageChange,
|
||||
isPaginationSticky = false,
|
||||
showRowCount = true,
|
||||
expandable,
|
||||
}: TableCollectionProps<T>) {
|
||||
const mappedColumns = useMemo(
|
||||
() => mapColumns<T>(columns, headerGroups, columnsForWrapText),
|
||||
@@ -315,6 +317,7 @@ function TableCollection<T extends object>({
|
||||
isPaginationSticky={isPaginationSticky}
|
||||
showRowCount={showRowCount}
|
||||
rowClassName={getRowClassName}
|
||||
expandable={expandable}
|
||||
components={{
|
||||
header: {
|
||||
cell: (props: HTMLAttributes<HTMLTableCellElement>) => {
|
||||
|
||||
@@ -182,10 +182,7 @@ testWithAssets(
|
||||
// Now track POST /api/v1/chart/data requests around Clear All
|
||||
const postsAfterClearAll: string[] = [];
|
||||
const handler = (req: any) => {
|
||||
if (
|
||||
req.url().includes('/api/v1/chart/data') &&
|
||||
req.method() === 'POST'
|
||||
) {
|
||||
if (req.url().includes('/api/v1/chart/data') && req.method() === 'POST') {
|
||||
postsAfterClearAll.push(req.url());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regression for #29519: a dashboard-level filter that is in scope for a Mixed
|
||||
* (mixed_timeseries) chart should apply to BOTH of the chart's queries — Query
|
||||
* A and Query B — not just Query A.
|
||||
*
|
||||
* A Mixed chart issues a single query context with two queries
|
||||
* (queries[0] = A, queries[1] = B). This test creates a Mixed chart, puts it on
|
||||
* a dashboard behind a native filter scoped to the chart, loads the dashboard,
|
||||
* and inspects the outgoing POST /api/v1/chart/data payload to assert the filter
|
||||
* is present in both queries.
|
||||
*
|
||||
* CI green => both queries inherit the dashboard filter (contract holds);
|
||||
* merging closes #29519 and guards against regressions.
|
||||
* CI red => Query B dropped the filter; the bug is live in the Mixed chart
|
||||
* query-building path (plugin-chart-echarts/src/MixedTimeseries).
|
||||
*/
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiPost, apiPut } from '../../helpers/api/requests';
|
||||
import { apiPostDashboard } from '../../helpers/api/dashboard';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
const FILTER_COLUMN = 'gender';
|
||||
const FILTER_VALUE = 'boy';
|
||||
|
||||
async function findDatasetIdByName(page: any, name: string): Promise<number> {
|
||||
const query = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
|
||||
const resp = await page.request.get(`api/v1/dataset/?q=${query}`);
|
||||
const body = await resp.json();
|
||||
if (!body.result?.length) {
|
||||
throw new Error(`Dataset ${name} not found`);
|
||||
}
|
||||
return body.result[0].id;
|
||||
}
|
||||
|
||||
testWithAssets(
|
||||
'Mixed chart applies dashboard filter to both queries (#29519)',
|
||||
async ({ page, testAssets }) => {
|
||||
const datasetId = await findDatasetIdByName(page, DATASET_NAME);
|
||||
|
||||
const chartParams = {
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'mixed_timeseries',
|
||||
x_axis: 'ds',
|
||||
time_grain_sqla: 'P1Y',
|
||||
metrics: ['count'],
|
||||
groupby: [],
|
||||
adhoc_filters: [],
|
||||
metrics_b: ['count'],
|
||||
groupby_b: [],
|
||||
adhoc_filters_b: [],
|
||||
row_limit: 100,
|
||||
row_limit_b: 100,
|
||||
truncate_metric: true,
|
||||
truncate_metric_b: true,
|
||||
comparison_type: 'values',
|
||||
color_scheme: 'supersetColors',
|
||||
};
|
||||
const chartResp = await apiPost(page, 'api/v1/chart/', {
|
||||
slice_name: `mixed_filter_repro_${Date.now()}`,
|
||||
viz_type: 'mixed_timeseries',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
params: JSON.stringify(chartParams),
|
||||
});
|
||||
expect(chartResp.ok()).toBe(true);
|
||||
const chartId: number = (await chartResp.json()).id;
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
const chartLayoutKey = `CHART-${chartId}`;
|
||||
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const positionJson = {
|
||||
DASHBOARD_VERSION_KEY: 'v2',
|
||||
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
|
||||
GRID_ID: {
|
||||
type: 'GRID',
|
||||
id: 'GRID_ID',
|
||||
children: ['ROW-1'],
|
||||
parents: ['ROOT_ID'],
|
||||
},
|
||||
'ROW-1': {
|
||||
type: 'ROW',
|
||||
id: 'ROW-1',
|
||||
children: [chartLayoutKey],
|
||||
parents: ['ROOT_ID', 'GRID_ID'],
|
||||
meta: { background: 'BACKGROUND_TRANSPARENT' },
|
||||
},
|
||||
[chartLayoutKey]: {
|
||||
type: 'CHART',
|
||||
id: chartLayoutKey,
|
||||
children: [],
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
|
||||
meta: { chartId, width: 8, height: 60, sliceName: 'mixed_filter_repro' },
|
||||
},
|
||||
};
|
||||
const jsonMetadata = {
|
||||
native_filter_configuration: [
|
||||
{
|
||||
id: filterId,
|
||||
name: 'Gender',
|
||||
filterType: 'filter_select',
|
||||
type: 'NATIVE_FILTER',
|
||||
targets: [{ datasetId, column: { name: FILTER_COLUMN } }],
|
||||
controlValues: {
|
||||
multiSelect: false,
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: false,
|
||||
inverseSelection: false,
|
||||
searchAllOptions: false,
|
||||
},
|
||||
defaultDataMask: {
|
||||
filterState: { value: [FILTER_VALUE] },
|
||||
extraFormData: {
|
||||
filters: [
|
||||
{ col: FILTER_COLUMN, op: 'IN', val: [FILTER_VALUE] },
|
||||
],
|
||||
},
|
||||
},
|
||||
cascadeParentIds: [],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
],
|
||||
chart_configuration: {},
|
||||
cross_filters_enabled: false,
|
||||
global_chart_configuration: {
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
};
|
||||
const dashResp = await apiPostDashboard(page, {
|
||||
dashboard_title: `mixed_filter_repro_${Date.now()}`,
|
||||
published: true,
|
||||
position_json: JSON.stringify(positionJson),
|
||||
json_metadata: JSON.stringify(jsonMetadata),
|
||||
});
|
||||
expect(dashResp.ok()).toBe(true);
|
||||
const dashBody = await dashResp.json();
|
||||
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
|
||||
testAssets.trackDashboard(dashboardId);
|
||||
|
||||
await apiPut(page, `api/v1/chart/${chartId}`, { dashboards: [dashboardId] });
|
||||
|
||||
// Capture the Mixed chart's data request (the one with two queries).
|
||||
const twoQueryPayloads: any[] = [];
|
||||
page.on('request', req => {
|
||||
if (
|
||||
req.url().includes('/api/v1/chart/data') &&
|
||||
req.method() === 'POST'
|
||||
) {
|
||||
try {
|
||||
const body = req.postDataJSON();
|
||||
if (body?.queries?.length === 2) {
|
||||
twoQueryPayloads.push(body);
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON bodies
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.gotoById(dashboardId);
|
||||
await dashboardPage.waitForLoad();
|
||||
await dashboardPage.waitForChartsToLoad();
|
||||
|
||||
await expect
|
||||
.poll(() => twoQueryPayloads.length, { timeout: 15_000 })
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const payload = twoQueryPayloads[twoQueryPayloads.length - 1];
|
||||
const filtersA = JSON.stringify(payload.queries[0].filters || []);
|
||||
const filtersB = JSON.stringify(payload.queries[1].filters || []);
|
||||
|
||||
expect(
|
||||
filtersA.includes(FILTER_COLUMN),
|
||||
'Query A should inherit the dashboard filter',
|
||||
).toBe(true);
|
||||
expect(
|
||||
filtersB.includes(FILTER_COLUMN),
|
||||
'Query B should inherit the dashboard filter (see #29519)',
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { views } from 'src/core';
|
||||
import { loadExtensionSettings } from 'src/core/extensions';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
import ChatbotMount from '.';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
// The settings store is a module singleton; reset it to the empty default
|
||||
// (no admin pin) before each test by loading from a mocked API response.
|
||||
jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: { result: { active_chatbot_id: null } },
|
||||
} as any);
|
||||
await loadExtensionSettings();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
disposables.length = 0;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('renders nothing when no chatbot extension is registered', async () => {
|
||||
render(<ChatbotMount />);
|
||||
|
||||
// Wait a tick for the settings load to resolve; the corner must stay empty
|
||||
// even after the gate opens (no chatbot registered → nothing to render).
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByTestId('chatbot-mount')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the registered chatbot inside the fixed mount slot', async () => {
|
||||
const provider = () => <div>My Chatbot Bubble</div>;
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatbotMount />);
|
||||
|
||||
// findBy* awaits the re-render after the initial settings load resolves.
|
||||
expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument();
|
||||
expect(screen.getByText('My Chatbot Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders only the first-to-register chatbot when several are installed', async () => {
|
||||
const firstProvider = () => <div>First Bubble</div>;
|
||||
const secondProvider = () => <div>Second Bubble</div>;
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
firstProvider,
|
||||
),
|
||||
views.registerView(
|
||||
{ id: 'second.chatbot', name: 'Second Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
secondProvider,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatbotMount />);
|
||||
|
||||
expect(await screen.findByText('First Bubble')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('isolates a failing chatbot so it does not crash the host', async () => {
|
||||
const FailingChatbot = () => {
|
||||
throw new Error('chatbot blew up');
|
||||
};
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
() => <FailingChatbot />,
|
||||
),
|
||||
);
|
||||
|
||||
// The host-owned error boundary catches the failure; render does not throw.
|
||||
expect(() => render(<ChatbotMount />)).not.toThrow();
|
||||
// The mount slot still renders post-gate (the boundary lives inside it);
|
||||
// awaiting it confirms the provider was actually exercised and contained.
|
||||
expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('isolates a chatbot whose provider function itself throws', async () => {
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
() => {
|
||||
throw new Error('provider blew up');
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ChatbotRenderer wraps provider() in a component so ErrorBoundary catches
|
||||
// synchronous throws from the provider function, not just from its output.
|
||||
expect(() => render(<ChatbotMount />)).not.toThrow();
|
||||
expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument();
|
||||
});
|
||||
124
superset-frontend/src/components/ChatbotMount/index.tsx
Normal file
124
superset-frontend/src/components/ChatbotMount/index.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
type ReactElement,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { store } from 'src/views/store';
|
||||
import { getActiveChatbot } from 'src/core/chatbot';
|
||||
import { subscribeToRegistry, getRegistryVersion } from 'src/core/views';
|
||||
import {
|
||||
getExtensionSettingsSnapshot,
|
||||
loadExtensionSettings,
|
||||
subscribeToExtensionSettings,
|
||||
} from 'src/core/extensions';
|
||||
|
||||
const CHATBOT_EDGE_MARGIN = 24;
|
||||
|
||||
/**
|
||||
* Wraps the chatbot provider in a React component so that ErrorBoundary can
|
||||
* catch synchronous throws from the provider function itself. Calling
|
||||
* `provider()` inline (e.g. `{activeChatbot.provider()}`) would throw outside
|
||||
* React's render boundary and crash the host.
|
||||
*/
|
||||
const ChatbotRenderer = ({ provider }: { provider: () => ReactElement }) =>
|
||||
provider();
|
||||
|
||||
const ChatbotMount = () => {
|
||||
const theme = useTheme();
|
||||
// Notify once per mount; a crash can re-render and would otherwise re-toast.
|
||||
const crashNotified = useRef(false);
|
||||
// Defer chatbot resolution until the first settings load resolves. Otherwise
|
||||
// the initial empty-default snapshot (no pin) would briefly resolve the
|
||||
// first-registered chatbot even when the DB pins a different one, mounting
|
||||
// the wrong provider until the async settings response arrives.
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
|
||||
// The active chatbot is a function of two host-owned stores: the admin
|
||||
// settings (active chatbot id) and the view registry (which chatbots are
|
||||
// registered). Both are read via useSyncExternalStore so this re-resolves
|
||||
// when either changes — no local copy of the settings state.
|
||||
const settings = useSyncExternalStore(
|
||||
subscribeToExtensionSettings,
|
||||
getExtensionSettingsSnapshot,
|
||||
);
|
||||
const registryVersion = useSyncExternalStore(
|
||||
subscribeToRegistry,
|
||||
getRegistryVersion,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Settings fetch failure is non-fatal: the store keeps its empty default,
|
||||
// which getActiveChatbot treats as "no admin pin" (falls back to the
|
||||
// first-registered chatbot). Either way, unblock rendering once the request
|
||||
// settles so a failed fetch never permanently hides the chatbot.
|
||||
loadExtensionSettings()
|
||||
.catch(() => {})
|
||||
.finally(() => setSettingsLoaded(true));
|
||||
}, []);
|
||||
|
||||
const activeChatbot = useMemo(
|
||||
() => getActiveChatbot(settings.active_chatbot_id),
|
||||
[settings, registryVersion],
|
||||
);
|
||||
|
||||
if (!settingsLoaded || !activeChatbot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-test="chatbot-mount"
|
||||
css={css`
|
||||
position: fixed;
|
||||
right: ${CHATBOT_EDGE_MARGIN}px;
|
||||
bottom: ${CHATBOT_EDGE_MARGIN}px;
|
||||
/* Above dashboard content and the toast layer, below modal dialogs. */
|
||||
z-index: ${theme.zIndexPopupBase + 2};
|
||||
`}
|
||||
>
|
||||
<ErrorBoundary
|
||||
showMessage={false}
|
||||
onError={(error: Error) => {
|
||||
// Fault isolation (SIP §4.5): contain the crash, log it, surface a
|
||||
// one-time notification, and leave the corner empty rather than
|
||||
// parking a persistent error card.
|
||||
logging.error('[chatbot] provider crashed', error);
|
||||
if (!crashNotified.current) {
|
||||
crashNotified.current = true;
|
||||
store.dispatch(addDangerToast(t('The chatbot failed to load.')));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChatbotRenderer provider={activeChatbot.provider} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatbotMount;
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -313,6 +313,8 @@ export interface ListViewProps<T extends object = any> {
|
||||
clearFilters: () => void;
|
||||
clearFilterById: (id: string) => void;
|
||||
}>;
|
||||
/** Optional expandable row configuration, passed through to antd Table. */
|
||||
expandable?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function ListView<T extends object = any>({
|
||||
@@ -340,6 +342,7 @@ export function ListView<T extends object = any>({
|
||||
enableBulkTag = false,
|
||||
bulkTagResourceName,
|
||||
filtersRef,
|
||||
expandable,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
}: ListViewProps<T>) {
|
||||
@@ -593,6 +596,7 @@ export function ListView<T extends object = any>({
|
||||
loading={loading && rows.length > 0}
|
||||
highlightRowId={highlightRowId}
|
||||
columnsForWrapText={columnsForWrapText}
|
||||
expandable={expandable}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
selectedFlatRows={selectedFlatRows}
|
||||
toggleRowSelected={(rowId, value) => {
|
||||
|
||||
132
superset-frontend/src/core/chatbot/index.test.ts
Normal file
132
superset-frontend/src/core/chatbot/index.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { views } from 'src/core/views';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
import { getActiveChatbot } from './index';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
disposables.length = 0;
|
||||
});
|
||||
|
||||
test('getActiveChatbot returns undefined when no chatbot is registered', () => {
|
||||
expect(getActiveChatbot()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getActiveChatbot resolves the single registered chatbot', () => {
|
||||
const provider = () => React.createElement('div', null, 'Chatbot');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
const active = getActiveChatbot();
|
||||
expect(active).toEqual({ id: 'superset.chatbot', provider });
|
||||
});
|
||||
|
||||
test('getActiveChatbot picks the first-to-register when multiple are installed', () => {
|
||||
const firstProvider = () => React.createElement('div', null, 'First');
|
||||
const secondProvider = () => React.createElement('div', null, 'Second');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
firstProvider,
|
||||
),
|
||||
views.registerView(
|
||||
{ id: 'second.chatbot', name: 'Second Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
secondProvider,
|
||||
),
|
||||
);
|
||||
|
||||
const active = getActiveChatbot();
|
||||
expect(active?.id).toBe('first.chatbot');
|
||||
expect(active?.provider).toBe(firstProvider);
|
||||
});
|
||||
|
||||
test('getActiveChatbot ignores views registered at other locations', () => {
|
||||
const provider = () => React.createElement('div', null, 'Panel');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'some.panel', name: 'Some Panel' },
|
||||
'sqllab.panels',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getActiveChatbot()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getActiveChatbot stops resolving a chatbot once it is disposed', () => {
|
||||
const provider = () => React.createElement('div', null, 'Chatbot');
|
||||
const disposable = views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
);
|
||||
|
||||
expect(getActiveChatbot()?.id).toBe('superset.chatbot');
|
||||
|
||||
disposable.dispose();
|
||||
|
||||
expect(getActiveChatbot()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getActiveChatbot honours the admin-pinned selection', () => {
|
||||
const firstProvider = () => React.createElement('div', null, 'First');
|
||||
const secondProvider = () => React.createElement('div', null, 'Second');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
firstProvider,
|
||||
),
|
||||
views.registerView(
|
||||
{ id: 'second.chatbot', name: 'Second Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
secondProvider,
|
||||
),
|
||||
);
|
||||
|
||||
const active = getActiveChatbot('second.chatbot');
|
||||
expect(active?.id).toBe('second.chatbot');
|
||||
expect(active?.provider).toBe(secondProvider);
|
||||
});
|
||||
|
||||
test('getActiveChatbot falls back to first-registered when pinned id is unknown', () => {
|
||||
const provider = () => React.createElement('div', null, 'First');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
// 'stale.chatbot' was once the admin pin but is no longer registered.
|
||||
const active = getActiveChatbot('stale.chatbot');
|
||||
expect(active?.id).toBe('first.chatbot');
|
||||
});
|
||||
79
superset-frontend/src/core/chatbot/index.ts
Normal file
79
superset-frontend/src/core/chatbot/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
/**
|
||||
* @fileoverview Host-internal resolver for the exclusive `superset.chatbot`
|
||||
* contribution area.
|
||||
*
|
||||
* `superset.chatbot` is a singleton contribution area: multiple chatbot
|
||||
* extensions may register a view there, but the host renders exactly one.
|
||||
* This module owns the host-side selection policy.
|
||||
*
|
||||
* This is host-internal infrastructure — it is NOT part of the public
|
||||
* `@apache-superset/core` API. Extensions register via the public
|
||||
* `views.registerView()`; only the host resolves which one is active.
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
import { getRegisteredViewIds, getViewProvider } from 'src/core/views';
|
||||
|
||||
/**
|
||||
* The resolved active chatbot: a view id paired with its renderable provider.
|
||||
*/
|
||||
export interface ActiveChatbot {
|
||||
/** The registered view id of the selected chatbot. */
|
||||
id: string;
|
||||
/** The provider that renders the chatbot's React element. */
|
||||
provider: () => ReactElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which single chatbot extension is currently active.
|
||||
*
|
||||
* Selection policy:
|
||||
* - If no chatbot is registered, returns `undefined` — the corner stays empty.
|
||||
* - If `adminSelectedId` matches a registered chatbot, that one wins.
|
||||
* - Otherwise the first-registered chatbot is used as a fallback.
|
||||
* The active chatbot pin is set only via the backend DB; when no pin is set
|
||||
* (active_chatbot_id is null), the fallback is the first-registered chatbot.
|
||||
*
|
||||
* @param adminSelectedId The id stored in the DB "Default chatbot" setting, if any.
|
||||
* @returns The active chatbot's id and provider, or `undefined` if none.
|
||||
*/
|
||||
export const getActiveChatbot = (
|
||||
adminSelectedId?: string | null,
|
||||
): ActiveChatbot | undefined => {
|
||||
const registeredIds = getRegisteredViewIds(CHATBOT_LOCATION);
|
||||
if (registeredIds.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// When the DB pin names a registered candidate, use it; otherwise fall back
|
||||
// to the first registered chatbot in registration order.
|
||||
// `getRegisteredViewIds` and `getViewProvider` read the same synchronous
|
||||
// registry maps, so a candidate id always has a live provider; the final
|
||||
// guard is cheap defensiveness, not a fallback path.
|
||||
const selectedId =
|
||||
adminSelectedId && registeredIds.includes(adminSelectedId)
|
||||
? adminSelectedId
|
||||
: registeredIds[0];
|
||||
|
||||
const provider = getViewProvider(CHATBOT_LOCATION, selectedId);
|
||||
return provider ? { id: selectedId, provider } : undefined;
|
||||
};
|
||||
220
superset-frontend/src/core/dashboard/index.test.ts
Normal file
220
superset-frontend/src/core/dashboard/index.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Captured listeners — allows tests to trigger action notifications manually.
|
||||
// ---------------------------------------------------------------------------
|
||||
type ListenerEntry = {
|
||||
predicate: (action: { type: string }) => boolean;
|
||||
effect: (action: { type: string }) => void;
|
||||
};
|
||||
|
||||
const capturedListeners: ListenerEntry[] = [];
|
||||
|
||||
// Declared before jest.mock so the factory closure can reference it.
|
||||
let mockState: Record<string, unknown>;
|
||||
|
||||
jest.mock('src/views/store', () => ({
|
||||
store: { getState: () => mockState, dispatch: jest.fn() },
|
||||
listenerMiddleware: {
|
||||
startListening: (opts: {
|
||||
predicate: (action: { type: string }) => boolean;
|
||||
effect: (action: { type: string }) => void;
|
||||
}) => {
|
||||
const entry = { predicate: opts.predicate, effect: opts.effect };
|
||||
capturedListeners.push(entry);
|
||||
return () => {
|
||||
const idx = capturedListeners.indexOf(entry);
|
||||
if (idx !== -1) capturedListeners.splice(idx, 1);
|
||||
};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../navigation', () => ({
|
||||
navigation: { getPageType: jest.fn(() => 'dashboard') },
|
||||
}));
|
||||
|
||||
function dispatch(actionType: string) {
|
||||
const action = { type: actionType };
|
||||
capturedListeners
|
||||
.filter(e => e.predicate(action))
|
||||
.forEach(e => e.effect(action));
|
||||
}
|
||||
|
||||
// Imported after mocks
|
||||
// eslint-disable-next-line import/first
|
||||
import { dashboard } from './index';
|
||||
|
||||
function makeState(
|
||||
overrides: Partial<{
|
||||
dashboardInfo: unknown;
|
||||
nativeFilters: unknown;
|
||||
dataMask: unknown;
|
||||
sliceEntities: unknown;
|
||||
dashboardLayout: unknown;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
dashboardInfo: { id: 1, dashboard_title: 'Sales', slug: 'sales' },
|
||||
nativeFilters: { filters: { 'filter-1': { name: 'Region' } } },
|
||||
dataMask: { 'filter-1': { filterState: { value: ['West'] } } },
|
||||
sliceEntities: { slices: {} },
|
||||
dashboardLayout: { present: {} },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockState = makeState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
capturedListeners.length = 0;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('getCurrentDashboard returns undefined when not on dashboard page', () => {
|
||||
const { navigation } = jest.requireMock('../navigation');
|
||||
(navigation.getPageType as jest.Mock).mockReturnValueOnce('explore');
|
||||
expect(dashboard.getCurrentDashboard()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getCurrentDashboard returns undefined when dashboardInfo is absent', () => {
|
||||
mockState = makeState({ dashboardInfo: undefined });
|
||||
expect(dashboard.getCurrentDashboard()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getCurrentDashboard returns dashboard context with active filters', () => {
|
||||
expect(dashboard.getCurrentDashboard()).toEqual({
|
||||
dashboardId: 1,
|
||||
title: 'Sales',
|
||||
filters: [{ filterId: 'filter-1', label: 'Region', value: ['West'] }],
|
||||
// No charts on the (empty) layout fixture.
|
||||
charts: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('getCurrentDashboard reports charts placed on the dashboard layout', () => {
|
||||
mockState = makeState({
|
||||
sliceEntities: {
|
||||
slices: {
|
||||
42: {
|
||||
slice_name: 'Revenue by Region',
|
||||
viz_type: 'echarts_timeseries_bar',
|
||||
datasource_id: 7,
|
||||
datasource_name: 'cleaned_sales',
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboardLayout: {
|
||||
present: {
|
||||
'CHART-abc': { id: 'CHART-abc', type: 'CHART', meta: { chartId: 42 } },
|
||||
// A chart id with no matching slice entity still appears, with blanks.
|
||||
'CHART-def': { id: 'CHART-def', type: 'CHART', meta: { chartId: 99 } },
|
||||
// Non-chart components are ignored.
|
||||
'TAB-xyz': { id: 'TAB-xyz', type: 'TAB', meta: {} },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(dashboard.getCurrentDashboard()?.charts).toEqual([
|
||||
{
|
||||
chartId: 42,
|
||||
chartName: 'Revenue by Region',
|
||||
vizType: 'echarts_timeseries_bar',
|
||||
datasourceId: 7,
|
||||
datasourceName: 'cleaned_sales',
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
chartId: 99,
|
||||
chartName: '',
|
||||
vizType: '',
|
||||
datasourceId: null,
|
||||
datasourceName: null,
|
||||
isVisible: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('getCurrentDashboard excludes filters with null value', () => {
|
||||
mockState = makeState({
|
||||
dataMask: { 'filter-1': { filterState: { value: null } } },
|
||||
});
|
||||
expect(dashboard.getCurrentDashboard()?.filters).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('getCurrentDashboard excludes dataMask entries not in nativeFilters', () => {
|
||||
mockState = makeState({
|
||||
dataMask: { 'chart-filter': { filterState: { value: 'foo' } } },
|
||||
});
|
||||
expect(dashboard.getCurrentDashboard()?.filters).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('filter array value is a defensive copy — mutation does not affect Redux state', () => {
|
||||
const ctx = dashboard.getCurrentDashboard();
|
||||
const original = [
|
||||
...(mockState as any).dataMask['filter-1'].filterState.value,
|
||||
];
|
||||
(ctx!.filters[0].value as string[]).push('East');
|
||||
expect((mockState as any).dataMask['filter-1'].filterState.value).toEqual(
|
||||
original,
|
||||
);
|
||||
});
|
||||
|
||||
// Action type strings match the constants in src/dashboard/actions/hydrate
|
||||
// and src/dataMask/actions — kept as literals so this test file has no
|
||||
// import dependency on those modules.
|
||||
test.each([
|
||||
'HYDRATE_DASHBOARD',
|
||||
'UPDATE_DATA_MASK',
|
||||
'SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE',
|
||||
])('onDidChangeDashboard fires on action type %s', actionType => {
|
||||
const listener = jest.fn();
|
||||
const disposable = dashboard.onDidChangeDashboard(listener);
|
||||
|
||||
dispatch(actionType);
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dashboardId: 1, title: 'Sales' }),
|
||||
);
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('onDidChangeDashboard does not fire when not on dashboard page', () => {
|
||||
const { navigation } = jest.requireMock('../navigation');
|
||||
(navigation.getPageType as jest.Mock).mockReturnValue('explore');
|
||||
|
||||
const listener = jest.fn();
|
||||
const disposable = dashboard.onDidChangeDashboard(listener);
|
||||
dispatch('HYDRATE_DASHBOARD');
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
(navigation.getPageType as jest.Mock).mockReturnValue('dashboard');
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('disposed listener is not called', () => {
|
||||
const listener = jest.fn();
|
||||
const disposable = dashboard.onDidChangeDashboard(listener);
|
||||
disposable.dispose();
|
||||
dispatch('HYDRATE_DASHBOARD');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
123
superset-frontend/src/core/dashboard/index.ts
Normal file
123
superset-frontend/src/core/dashboard/index.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Host-internal implementation of the `dashboard` namespace.
|
||||
*
|
||||
* Wraps Redux dashboardInfo and dataMask state and normalizes them into the
|
||||
* stable `DashboardContext` contract. Extensions must not depend on the Redux
|
||||
* slice structure directly.
|
||||
*/
|
||||
|
||||
import type { dashboard as dashboardApi } from '@apache-superset/core';
|
||||
import type { DataMaskStateWithId } from '@superset-ui/core';
|
||||
import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
|
||||
import {
|
||||
UPDATE_DATA_MASK,
|
||||
SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE,
|
||||
} from 'src/dataMask/actions';
|
||||
import { store, RootState } from 'src/views/store';
|
||||
import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
||||
import getChartIdsFromLayout from 'src/dashboard/util/getChartIdsFromLayout';
|
||||
import { createActionListener } from '../utils';
|
||||
import { navigation } from '../navigation';
|
||||
|
||||
type DashboardContext = dashboardApi.DashboardContext;
|
||||
type FilterValue = dashboardApi.FilterValue;
|
||||
type ChartSummary = NonNullable<DashboardContext['charts']>[number];
|
||||
|
||||
function buildChartSummaries(state: RootState): ChartSummary[] {
|
||||
const slices = state.sliceEntities?.slices ?? {};
|
||||
const layout = state.dashboardLayout?.present ?? {};
|
||||
|
||||
// Only charts actually placed on the dashboard layout — `slices` can also
|
||||
// hold entities that are not on the current dashboard.
|
||||
return getChartIdsFromLayout(layout).map(chartId => {
|
||||
const slice = slices[chartId];
|
||||
return {
|
||||
chartId,
|
||||
chartName: slice?.slice_name ?? '',
|
||||
vizType: slice?.viz_type ?? '',
|
||||
datasourceId: slice?.datasource_id ?? null,
|
||||
datasourceName: slice?.datasource_name ?? null,
|
||||
// Tab-accurate visibility is a deferred phase (SIP §10/§11); every chart
|
||||
// on the dashboard is reported visible for now.
|
||||
isVisible: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildDashboardContext(): DashboardContext | undefined {
|
||||
if (navigation.getPageType() !== 'dashboard') return undefined;
|
||||
// `store.getState()` is already typed as RootState, so the slices below are
|
||||
// read with their real types — the host owns this normalization and must
|
||||
// stay type-safe against slice reshapes.
|
||||
const state = store.getState();
|
||||
const info = state.dashboardInfo;
|
||||
if (!info?.id) return undefined;
|
||||
|
||||
const nativeFilters = state.nativeFilters?.filters ?? {};
|
||||
const dataMask: DataMaskStateWithId = state.dataMask ?? {};
|
||||
|
||||
const filters: FilterValue[] = Object.entries(dataMask)
|
||||
.filter(([id, mask]) => {
|
||||
if (!(id in nativeFilters)) return false;
|
||||
const value = mask?.filterState?.value;
|
||||
return value !== null && value !== undefined;
|
||||
})
|
||||
.map(([id, mask]) => {
|
||||
const raw = mask.filterState?.value;
|
||||
return {
|
||||
filterId: id,
|
||||
label: nativeFilters[id]?.name ?? id,
|
||||
value: Array.isArray(raw) ? [...raw] : raw,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
dashboardId: info.id,
|
||||
title: info.dashboard_title ?? info.slug ?? String(info.id),
|
||||
filters,
|
||||
charts: buildChartSummaries(state),
|
||||
};
|
||||
}
|
||||
|
||||
const dashboardChangePredicate: AnyListenerPredicate<RootState> = action =>
|
||||
action.type === HYDRATE_DASHBOARD ||
|
||||
action.type === UPDATE_DATA_MASK ||
|
||||
action.type === SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE;
|
||||
|
||||
const getCurrentDashboard: typeof dashboardApi.getCurrentDashboard = () =>
|
||||
buildDashboardContext();
|
||||
|
||||
const onDidChangeDashboard: typeof dashboardApi.onDidChangeDashboard = (
|
||||
listener: (ctx: DashboardContext) => void,
|
||||
thisArgs?: any,
|
||||
) =>
|
||||
createActionListener<DashboardContext>(
|
||||
dashboardChangePredicate,
|
||||
listener,
|
||||
() => buildDashboardContext() ?? null,
|
||||
thisArgs,
|
||||
);
|
||||
|
||||
export const dashboard: typeof dashboardApi = {
|
||||
getCurrentDashboard,
|
||||
onDidChangeDashboard,
|
||||
};
|
||||
63
superset-frontend/src/core/dataset/index.ts
Normal file
63
superset-frontend/src/core/dataset/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Host-internal implementation of the `dataset` namespace.
|
||||
*
|
||||
* Dataset page components call `setCurrentDataset` to publish context as they
|
||||
* load. Extensions consume the stable `DatasetContext` contract; they are
|
||||
* isolated from the page's internal data-fetching implementation.
|
||||
*/
|
||||
|
||||
import type { dataset as datasetApi } from '@apache-superset/core';
|
||||
import { createEmitter } from '../utils';
|
||||
|
||||
type DatasetContext = datasetApi.DatasetContext;
|
||||
|
||||
const emitter = createEmitter<DatasetContext | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Host-internal: called by the Dataset page when its entity loads or changes.
|
||||
* Not part of the public `@apache-superset/core` API.
|
||||
*/
|
||||
export const setCurrentDataset = (ctx: DatasetContext | undefined): void => {
|
||||
emitter.fire(ctx);
|
||||
};
|
||||
|
||||
const getCurrentDataset: typeof datasetApi.getCurrentDataset = () => {
|
||||
const current = emitter.getCurrent();
|
||||
return current ? { ...current } : undefined;
|
||||
};
|
||||
|
||||
const onDidChangeDataset: typeof datasetApi.onDidChangeDataset = (
|
||||
listener: (ctx: DatasetContext) => void,
|
||||
thisArgs?: unknown,
|
||||
) => {
|
||||
const bound = thisArgs ? listener.bind(thisArgs) : listener;
|
||||
// The public contract only emits a concrete context; skip `undefined` clears
|
||||
// so subscribers are never handed an empty value.
|
||||
return emitter.event(ctx => {
|
||||
if (ctx) bound(ctx);
|
||||
});
|
||||
};
|
||||
|
||||
export const dataset: typeof datasetApi = {
|
||||
getCurrentDataset,
|
||||
onDidChangeDataset,
|
||||
};
|
||||
157
superset-frontend/src/core/explore/index.test.ts
Normal file
157
superset-frontend/src/core/explore/index.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Captured listeners — allows tests to trigger action notifications manually.
|
||||
// ---------------------------------------------------------------------------
|
||||
type ListenerEntry = {
|
||||
predicate: (action: { type: string }) => boolean;
|
||||
effect: (action: { type: string }) => void;
|
||||
};
|
||||
|
||||
const capturedListeners: ListenerEntry[] = [];
|
||||
|
||||
// Declared before jest.mock so the factory closure can reference it.
|
||||
let mockState: Record<string, unknown>;
|
||||
|
||||
jest.mock('src/views/store', () => ({
|
||||
store: { getState: () => mockState, dispatch: jest.fn() },
|
||||
listenerMiddleware: {
|
||||
startListening: (opts: {
|
||||
predicate: (action: { type: string }) => boolean;
|
||||
effect: (action: { type: string }) => void;
|
||||
}) => {
|
||||
const entry = { predicate: opts.predicate, effect: opts.effect };
|
||||
capturedListeners.push(entry);
|
||||
return () => {
|
||||
const idx = capturedListeners.indexOf(entry);
|
||||
if (idx !== -1) capturedListeners.splice(idx, 1);
|
||||
};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../navigation', () => ({
|
||||
navigation: { getPageType: jest.fn(() => 'explore') },
|
||||
}));
|
||||
|
||||
function dispatch(actionType: string) {
|
||||
const action = { type: actionType };
|
||||
capturedListeners
|
||||
.filter(e => e.predicate(action))
|
||||
.forEach(e => e.effect(action));
|
||||
}
|
||||
|
||||
// Imported after mocks
|
||||
// eslint-disable-next-line import/first
|
||||
import { explore } from './index';
|
||||
|
||||
beforeEach(() => {
|
||||
mockState = {
|
||||
explore: {
|
||||
slice: { slice_id: 42, slice_name: 'My Chart' },
|
||||
datasource: { id: 7, table_name: 'orders' },
|
||||
controls: { viz_type: { value: 'bar' } },
|
||||
sliceName: 'My Chart',
|
||||
form_data: {},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
capturedListeners.length = 0;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('getCurrentChart returns undefined when not on explore page', () => {
|
||||
const { navigation } = jest.requireMock('../navigation');
|
||||
(navigation.getPageType as jest.Mock).mockReturnValueOnce('dashboard');
|
||||
expect(explore.getCurrentChart()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getCurrentChart returns undefined when explore state is absent', () => {
|
||||
mockState = {};
|
||||
expect(explore.getCurrentChart()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getCurrentChart returns chart context from Redux state', () => {
|
||||
expect(explore.getCurrentChart()).toEqual({
|
||||
chartId: 42,
|
||||
chartName: 'My Chart',
|
||||
vizType: 'bar',
|
||||
datasourceId: 7,
|
||||
datasourceName: 'orders',
|
||||
});
|
||||
});
|
||||
|
||||
test('getCurrentChart returns null chartId for unsaved chart', () => {
|
||||
mockState = {
|
||||
explore: {
|
||||
slice: null,
|
||||
datasource: { id: 1, table_name: 'events' },
|
||||
controls: { viz_type: { value: 'line' } },
|
||||
sliceName: null,
|
||||
form_data: { viz_type: 'line' },
|
||||
},
|
||||
};
|
||||
expect(explore.getCurrentChart()?.chartId).toBeNull();
|
||||
});
|
||||
|
||||
// Action type strings match the constants in src/explore/actions/exploreActions
|
||||
// and src/explore/actions/datasourcesActions — kept as literals so this test
|
||||
// file has no import dependency on those modules.
|
||||
test.each([
|
||||
'HYDRATE_EXPLORE',
|
||||
'UPDATE_FORM_DATA', // SET_FORM_DATA constant resolves to this string
|
||||
'UPDATE_CHART_TITLE',
|
||||
'SET_DATASOURCE',
|
||||
'CREATE_NEW_SLICE',
|
||||
'SLICE_UPDATED',
|
||||
])('onDidChangeChart fires on action type %s', actionType => {
|
||||
const listener = jest.fn();
|
||||
const disposable = explore.onDidChangeChart(listener);
|
||||
|
||||
dispatch(actionType);
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ chartId: 42, vizType: 'bar' }),
|
||||
);
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('onDidChangeChart does not fire when page type is not explore', () => {
|
||||
const { navigation } = jest.requireMock('../navigation');
|
||||
(navigation.getPageType as jest.Mock).mockReturnValue('dashboard');
|
||||
|
||||
const listener = jest.fn();
|
||||
const disposable = explore.onDidChangeChart(listener);
|
||||
dispatch('HYDRATE_EXPLORE');
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
(navigation.getPageType as jest.Mock).mockReturnValue('explore');
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('disposed listener is not called', () => {
|
||||
const listener = jest.fn();
|
||||
const disposable = explore.onDidChangeChart(listener);
|
||||
disposable.dispose();
|
||||
dispatch('HYDRATE_EXPLORE');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
92
superset-frontend/src/core/explore/index.ts
Normal file
92
superset-frontend/src/core/explore/index.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Host-internal implementation of the `explore` namespace.
|
||||
*
|
||||
* Wraps Redux explore state and normalizes it into the stable `ChartContext`
|
||||
* contract. Extensions must not depend on the Redux slice structure directly.
|
||||
*/
|
||||
|
||||
import type { explore as exploreApi } from '@apache-superset/core';
|
||||
import { HYDRATE_EXPLORE } from 'src/explore/actions/hydrateExplore';
|
||||
import {
|
||||
CREATE_NEW_SLICE,
|
||||
SET_FORM_DATA,
|
||||
SLICE_UPDATED,
|
||||
UPDATE_CHART_TITLE,
|
||||
} from 'src/explore/actions/exploreActions';
|
||||
import { SET_DATASOURCE } from 'src/explore/actions/datasourcesActions';
|
||||
import { store, RootState } from 'src/views/store';
|
||||
import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
||||
import { createActionListener } from '../utils';
|
||||
import { navigation } from '../navigation';
|
||||
|
||||
type ChartContext = exploreApi.ChartContext;
|
||||
|
||||
function buildChartContext(): ChartContext | undefined {
|
||||
if (navigation.getPageType() !== 'explore') return undefined;
|
||||
// `store.getState()` is already RootState; read the typed `explore` slice
|
||||
// directly rather than casting it away.
|
||||
const state = store.getState();
|
||||
const exploreState = state.explore;
|
||||
if (!exploreState) return undefined;
|
||||
|
||||
const { slice, datasource, controls } = exploreState;
|
||||
const vizType: string =
|
||||
(controls?.viz_type?.value as string) ??
|
||||
exploreState.form_data?.viz_type ??
|
||||
'';
|
||||
|
||||
return {
|
||||
chartId: slice?.slice_id ?? null,
|
||||
chartName: exploreState.sliceName ?? slice?.slice_name ?? null,
|
||||
vizType,
|
||||
datasourceId: datasource?.id ?? null,
|
||||
datasourceName:
|
||||
datasource?.table_name ?? datasource?.datasource_name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const exploreChangePredicate: AnyListenerPredicate<RootState> = action =>
|
||||
action.type === HYDRATE_EXPLORE ||
|
||||
action.type === SET_FORM_DATA ||
|
||||
action.type === UPDATE_CHART_TITLE ||
|
||||
action.type === SET_DATASOURCE ||
|
||||
action.type === CREATE_NEW_SLICE ||
|
||||
action.type === SLICE_UPDATED;
|
||||
|
||||
const getCurrentChart: typeof exploreApi.getCurrentChart = () =>
|
||||
buildChartContext();
|
||||
|
||||
const onDidChangeChart: typeof exploreApi.onDidChangeChart = (
|
||||
listener: (ctx: ChartContext) => void,
|
||||
thisArgs?: any,
|
||||
) =>
|
||||
createActionListener<ChartContext>(
|
||||
exploreChangePredicate,
|
||||
listener,
|
||||
() => buildChartContext() ?? null,
|
||||
thisArgs,
|
||||
);
|
||||
|
||||
export const explore: typeof exploreApi = {
|
||||
getCurrentChart,
|
||||
onDidChangeChart,
|
||||
};
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { extensions as extensionsApi } from '@apache-superset/core';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import ExtensionsLoader from 'src/extensions/ExtensionsLoader';
|
||||
|
||||
const getExtension: typeof extensionsApi.getExtension = id =>
|
||||
@@ -29,3 +30,61 @@ export const extensions: typeof extensionsApi = {
|
||||
getExtension,
|
||||
getAllExtensions,
|
||||
};
|
||||
|
||||
/**
|
||||
* Deployment-wide extension admin settings. The keys are snake_case to match
|
||||
* the `/api/v1/extensions/settings` wire shape this store loads from.
|
||||
* Settings are read-only from the frontend; the admin write path has been
|
||||
* removed in favour of direct backend configuration.
|
||||
*/
|
||||
export type ExtensionSettings = {
|
||||
active_chatbot_id: string | null;
|
||||
};
|
||||
|
||||
const SETTINGS_ENDPOINT = '/api/v1/extensions/settings';
|
||||
|
||||
const EMPTY_SETTINGS: ExtensionSettings = {
|
||||
active_chatbot_id: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Single module-level store for extension admin settings. The chatbot mount
|
||||
* reads this one source via `useSyncExternalStore` so it re-resolves when the
|
||||
* store is updated — no bespoke second notification channel needed.
|
||||
*/
|
||||
let settings: ExtensionSettings = EMPTY_SETTINGS;
|
||||
const settingsListeners = new Set<() => void>();
|
||||
|
||||
const emitSettingsChange = (): void => {
|
||||
settingsListeners.forEach(fn => fn());
|
||||
};
|
||||
|
||||
/** Subscribe to settings changes (for `useSyncExternalStore`). */
|
||||
export const subscribeToExtensionSettings = (
|
||||
listener: () => void,
|
||||
): (() => void) => {
|
||||
settingsListeners.add(listener);
|
||||
return () => {
|
||||
settingsListeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
/** Current settings snapshot (for `useSyncExternalStore`). */
|
||||
export const getExtensionSettingsSnapshot = (): ExtensionSettings => settings;
|
||||
|
||||
/** Replace the settings snapshot and notify subscribers. Module-private; only loadExtensionSettings should call this. */
|
||||
const applyExtensionSettings = (next: ExtensionSettings): void => {
|
||||
settings = next;
|
||||
emitSettingsChange();
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch settings from the server into the store. Resolves to the loaded value;
|
||||
* on failure the store is left untouched and the error is rethrown so callers
|
||||
* can surface it.
|
||||
*/
|
||||
export const loadExtensionSettings = async (): Promise<ExtensionSettings> => {
|
||||
const { json } = await SupersetClient.get({ endpoint: SETTINGS_ENDPOINT });
|
||||
applyExtensionSettings(json.result ?? EMPTY_SETTINGS);
|
||||
return settings;
|
||||
};
|
||||
|
||||
@@ -28,10 +28,14 @@ export const core: typeof coreType = {
|
||||
|
||||
export * from './authentication';
|
||||
export * from './commands';
|
||||
export * from './dashboard';
|
||||
export * from './dataset';
|
||||
export * from './editors';
|
||||
export * from './explore';
|
||||
export * from './extensions';
|
||||
export * from './menus';
|
||||
export * from './models';
|
||||
export * from './navigation';
|
||||
export * from './sqlLab';
|
||||
export * from './utils';
|
||||
export * from './views';
|
||||
|
||||
121
superset-frontend/src/core/navigation/index.test.ts
Normal file
121
superset-frontend/src/core/navigation/index.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
// Reset module state between tests so currentPageType is re-initialized.
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { pathname: '/' },
|
||||
});
|
||||
});
|
||||
|
||||
async function importNavigation() {
|
||||
const mod = await import('./index');
|
||||
return mod;
|
||||
}
|
||||
|
||||
test('getPageType returns "other" for unknown pathname', async () => {
|
||||
const { navigation } = await importNavigation();
|
||||
expect(navigation.getPageType()).toBe('other');
|
||||
});
|
||||
|
||||
test('getPageType derives page type from window.location.pathname', async () => {
|
||||
window.location.pathname = '/superset/dashboard/42/';
|
||||
const { navigation } = await importNavigation();
|
||||
expect(navigation.getPageType()).toBe('dashboard');
|
||||
});
|
||||
|
||||
test('notifyPageChange updates the current page type', async () => {
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
notifyPageChange('/explore/?form_data={}');
|
||||
expect(navigation.getPageType()).toBe('explore');
|
||||
});
|
||||
|
||||
test('notifyPageChange fires listeners on page type change', async () => {
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
const listener = jest.fn();
|
||||
const disposable = navigation.onDidChangePage(listener);
|
||||
|
||||
notifyPageChange('/superset/dashboard/1/');
|
||||
expect(listener).toHaveBeenCalledWith('dashboard');
|
||||
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('notifyPageChange does not fire listeners when page type is unchanged', async () => {
|
||||
window.location.pathname = '/superset/dashboard/1/';
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
const listener = jest.fn();
|
||||
navigation.onDidChangePage(listener);
|
||||
|
||||
notifyPageChange('/superset/dashboard/2/');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('onDidChangePage listener is removed after dispose', async () => {
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
const listener = jest.fn();
|
||||
const disposable = navigation.onDidChangePage(listener);
|
||||
|
||||
disposable.dispose();
|
||||
notifyPageChange('/superset/dashboard/1/');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('sqllab path is matched with and without trailing slash', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/sqllab');
|
||||
expect(navigation.getPageType()).toBe('sqllab');
|
||||
notifyPageChange('/explore/');
|
||||
notifyPageChange('/sqllab/');
|
||||
expect(navigation.getPageType()).toBe('sqllab');
|
||||
});
|
||||
|
||||
test('chart and dashboard list pages get their own page types', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/chart/list/');
|
||||
expect(navigation.getPageType()).toBe('chart_list');
|
||||
notifyPageChange('/dashboard/list/');
|
||||
expect(navigation.getPageType()).toBe('dashboard_list');
|
||||
});
|
||||
|
||||
test('dataset list and single-dataset pages get distinct page types', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/tablemodelview/list/');
|
||||
expect(navigation.getPageType()).toBe('dataset_list');
|
||||
notifyPageChange('/dataset/42');
|
||||
expect(navigation.getPageType()).toBe('dataset');
|
||||
});
|
||||
|
||||
test('sqllab editor, query history, and saved queries get distinct page types', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/sqllab/');
|
||||
expect(navigation.getPageType()).toBe('sqllab');
|
||||
notifyPageChange('/sqllab/history/');
|
||||
expect(navigation.getPageType()).toBe('query_history');
|
||||
notifyPageChange('/savedqueryview/list/');
|
||||
expect(navigation.getPageType()).toBe('saved_queries');
|
||||
});
|
||||
|
||||
test('chart/add resolves to explore, not chart_list', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/chart/add');
|
||||
expect(navigation.getPageType()).toBe('explore');
|
||||
});
|
||||
82
superset-frontend/src/core/navigation/index.ts
Normal file
82
superset-frontend/src/core/navigation/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Host-internal implementation of the `navigation` namespace.
|
||||
*
|
||||
* Backed by browser location — no Redux dependency.
|
||||
* The app shell calls `notifyPageChange(pathname)` whenever the route changes.
|
||||
*/
|
||||
|
||||
import type { navigation as navigationApi } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
|
||||
type PageType = navigationApi.PageType;
|
||||
|
||||
const listeners = new Set<(pageType: PageType) => void>();
|
||||
|
||||
function derivePageType(pathname: string): PageType {
|
||||
if (pathname.startsWith('/superset/dashboard/')) return 'dashboard';
|
||||
if (pathname.startsWith('/dashboard/list')) return 'dashboard_list';
|
||||
if (pathname.startsWith('/explore/')) return 'explore';
|
||||
if (pathname.startsWith('/superset/explore/')) return 'explore';
|
||||
if (pathname.startsWith('/chart/add')) return 'explore';
|
||||
if (pathname.startsWith('/chart/list')) return 'chart_list';
|
||||
if (pathname.startsWith('/sqllab/history')) return 'query_history';
|
||||
if (pathname.startsWith('/savedqueryview/list')) return 'saved_queries';
|
||||
if (pathname === '/sqllab' || pathname.startsWith('/sqllab/'))
|
||||
return 'sqllab';
|
||||
if (pathname.startsWith('/tablemodelview/list')) return 'dataset_list';
|
||||
if (pathname.startsWith('/dataset/')) return 'dataset';
|
||||
if (pathname.startsWith('/superset/welcome/')) return 'home';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
let currentPageType: PageType | undefined;
|
||||
|
||||
function getOrInitPageType(): PageType {
|
||||
if (currentPageType === undefined) {
|
||||
currentPageType = derivePageType(window.location.pathname);
|
||||
}
|
||||
return currentPageType;
|
||||
}
|
||||
|
||||
/** Called by ExtensionsStartup whenever the React Router location changes. */
|
||||
export const notifyPageChange = (pathname: string): void => {
|
||||
const next = derivePageType(pathname);
|
||||
if (next === getOrInitPageType()) return;
|
||||
currentPageType = next;
|
||||
listeners.forEach(fn => fn(next));
|
||||
};
|
||||
|
||||
const getPageType: typeof navigationApi.getPageType = () => getOrInitPageType();
|
||||
|
||||
const onDidChangePage: typeof navigationApi.onDidChangePage = (
|
||||
listener: (pageType: PageType) => void,
|
||||
thisArgs?: any,
|
||||
): Disposable => {
|
||||
const bound = thisArgs ? listener.bind(thisArgs) : listener;
|
||||
listeners.add(bound);
|
||||
return new Disposable(() => listeners.delete(bound));
|
||||
};
|
||||
|
||||
export const navigation: typeof navigationApi = {
|
||||
getPageType,
|
||||
onDidChangePage,
|
||||
};
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
QueryResultContext,
|
||||
QueryErrorResultContext,
|
||||
} from './models';
|
||||
import { navigation } from '../navigation';
|
||||
|
||||
const { CTASMethod } = sqlLabApi;
|
||||
|
||||
@@ -301,8 +302,15 @@ function createQueryErrorContext(
|
||||
);
|
||||
}
|
||||
|
||||
const getCurrentTab: typeof sqlLabApi.getCurrentTab = () =>
|
||||
getTab(activeEditorId());
|
||||
const getCurrentTab: typeof sqlLabApi.getCurrentTab = () => {
|
||||
// Guard on the page type so the tab does not leak onto non-editor surfaces.
|
||||
// The SQL Lab Redux slice persists after navigating away, so without this
|
||||
// guard `getCurrentTab()` would keep returning the last editor's tab on, e.g.,
|
||||
// a dashboard or list page. Mirrors the page-type guards on
|
||||
// `explore.getCurrentChart()` / `dashboard.getCurrentDashboard()`.
|
||||
if (navigation.getPageType() !== 'sqllab') return undefined;
|
||||
return getTab(activeEditorId());
|
||||
};
|
||||
|
||||
const getActivePanel: typeof sqlLabApi.getActivePanel = () => {
|
||||
const { activeSouthPaneTab } = getSqlLabState();
|
||||
@@ -452,8 +460,14 @@ const onDidChangeActiveTab: typeof sqlLabApi.onDidChangeActiveTab = (
|
||||
createActionListener(
|
||||
globalPredicate(SET_ACTIVE_QUERY_EDITOR),
|
||||
listener,
|
||||
(action: { type: string; queryEditor: { id: string } }) =>
|
||||
getTab(action.queryEditor.id),
|
||||
// Resolve the now-active tab the same way `getCurrentTab()` does (via the
|
||||
// active-editor / tabHistory state) rather than from the raw action payload.
|
||||
// The action's `queryEditor` carries the base editor without `unsavedQueryEditor`
|
||||
// merged, so its `dbId` can still be undefined at this point, which made
|
||||
// `getTab(action.queryEditor.id)` return undefined and silently swallow the
|
||||
// event. Reading the resolved active tab keeps this event consistent with the
|
||||
// getter and fires on every tab switch.
|
||||
() => getCurrentTab() ?? null,
|
||||
thisArgs,
|
||||
);
|
||||
|
||||
|
||||
@@ -119,6 +119,13 @@ jest.mock('src/views/store', () => ({
|
||||
setupStore: jest.fn(),
|
||||
}));
|
||||
|
||||
// The sqlLab namespace guards `getCurrentTab()` on the page type. These tests
|
||||
// exercise the editor surface, so report 'sqllab'. Per-test overrides (e.g. to
|
||||
// assert the off-surface guard) can change the return value.
|
||||
jest.mock('../navigation', () => ({
|
||||
navigation: { getPageType: jest.fn(() => 'sqllab') },
|
||||
}));
|
||||
|
||||
// Module under test — imported after mocks
|
||||
// eslint-disable-next-line import/first
|
||||
import { sqlLab } from '.';
|
||||
@@ -388,6 +395,31 @@ test('onDidChangeActiveTab fires with Tab on SET_ACTIVE_QUERY_EDITOR', () => {
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('onDidChangeActiveTab carries the newly-activated tab when switching away', () => {
|
||||
// Switching from the first editor to a second one must report the second tab,
|
||||
// not the first. Regression guard: resolving the tab from the live active
|
||||
// editor (via getCurrentTab) instead of the raw action payload.
|
||||
mockStore.dispatch({
|
||||
type: ADD_QUERY_EDITOR,
|
||||
queryEditor: makeSecondEditor(),
|
||||
});
|
||||
|
||||
const listener = jest.fn();
|
||||
const disposable = sqlLab.onDidChangeActiveTab(listener);
|
||||
|
||||
mockStore.dispatch({
|
||||
type: SET_ACTIVE_QUERY_EDITOR,
|
||||
queryEditor: { id: 'editor-2' },
|
||||
});
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const tab = listener.mock.calls[0][0];
|
||||
expect(tab.id).toBe('editor-2');
|
||||
expect(tab.databaseId).toBe(2);
|
||||
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('onDidCreateTab fires with Tab on ADD_QUERY_EDITOR', () => {
|
||||
const listener = jest.fn();
|
||||
const disposable = sqlLab.onDidCreateTab(listener);
|
||||
@@ -535,6 +567,13 @@ test('getCurrentTab returns the active tab with correct properties', () => {
|
||||
expect(tab!.schema).toBe('public');
|
||||
});
|
||||
|
||||
test('getCurrentTab returns undefined when not on the SQL Lab editor surface', () => {
|
||||
const { navigation } = jest.requireMock('../navigation');
|
||||
(navigation.getPageType as jest.Mock).mockReturnValueOnce('dashboard');
|
||||
|
||||
expect(sqlLab.getCurrentTab()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getActivePanel returns the active south pane tab', () => {
|
||||
const panel = sqlLab.getActivePanel();
|
||||
expect(panel.id).toBe('Results');
|
||||
|
||||
@@ -20,6 +20,56 @@ import type { common as core } from '@apache-superset/core';
|
||||
import { AnyAction } from 'redux';
|
||||
import { listenerMiddleware, RootState, store } from 'src/views/store';
|
||||
import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
||||
import { Disposable } from './models';
|
||||
|
||||
/**
|
||||
* A typed event subscription matching the public `Event<T>` contract.
|
||||
* Calling it with a listener (and optional `this` arg) subscribes and returns
|
||||
* a {@link Disposable} that unsubscribes.
|
||||
*/
|
||||
export type EventSubscriber<T> = (
|
||||
listener: (e: T) => void,
|
||||
thisArgs?: unknown,
|
||||
) => Disposable;
|
||||
|
||||
/**
|
||||
* A minimal host-internal event emitter shared by the producer-backed
|
||||
* namespaces (dataset, navigation, settings, view registry). Each of those
|
||||
* needs the same "publish a value and fan it out to subscribers" primitive;
|
||||
* this collapses the duplicated Set + bind + Disposable boilerplate into one
|
||||
* place.
|
||||
*
|
||||
* `event` is exposed to extensions as the namespace's `onDidChange*`; `fire`
|
||||
* and `getCurrent` stay host-internal.
|
||||
*/
|
||||
export interface Emitter<T> {
|
||||
/** Subscribe to changes; conforms to the public `Event<T>` shape. */
|
||||
event: EventSubscriber<T>;
|
||||
/** Notify all current subscribers with `value`. */
|
||||
fire: (value: T) => void;
|
||||
/** The most recently fired value (or the initial value). */
|
||||
getCurrent: () => T;
|
||||
}
|
||||
|
||||
export function createEmitter<T>(initial: T): Emitter<T> {
|
||||
const listeners = new Set<(e: T) => void>();
|
||||
let current = initial;
|
||||
|
||||
return {
|
||||
event: (listener, thisArgs) => {
|
||||
const bound = thisArgs ? listener.bind(thisArgs) : listener;
|
||||
listeners.add(bound);
|
||||
return new Disposable(() => {
|
||||
listeners.delete(bound);
|
||||
});
|
||||
},
|
||||
fire: value => {
|
||||
current = value;
|
||||
listeners.forEach(fn => fn(value));
|
||||
},
|
||||
getCurrent: () => current,
|
||||
};
|
||||
}
|
||||
|
||||
export function createActionListener<V>(
|
||||
predicate: AnyListenerPredicate<RootState>,
|
||||
|
||||
@@ -17,7 +17,12 @@
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { views, resolveView } from './index';
|
||||
import {
|
||||
views,
|
||||
resolveView,
|
||||
getViewProvider,
|
||||
getRegisteredViewIds,
|
||||
} from './index';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
@@ -110,3 +115,59 @@ test('dispose removes the view registration', () => {
|
||||
|
||||
expect(views.getViews('sqllab.panels')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getViewProvider returns the registered provider for a matching location', () => {
|
||||
const provider = () => React.createElement('div', null, 'Test');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'test.provider', name: 'Test Provider' },
|
||||
'superset.chatbot',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getViewProvider('superset.chatbot', 'test.provider')).toBe(provider);
|
||||
});
|
||||
|
||||
test('getViewProvider returns undefined when the location does not match', () => {
|
||||
const provider = () => React.createElement('div', null, 'Test');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'test.provider', name: 'Test Provider' },
|
||||
'sqllab.panels',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
// Registered, but at a different location.
|
||||
expect(getViewProvider('superset.chatbot', 'test.provider')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getViewProvider returns undefined for an unknown id', () => {
|
||||
expect(getViewProvider('superset.chatbot', 'nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getRegisteredViewIds returns ids in registration order', () => {
|
||||
const provider = () => React.createElement('div', null, 'Test');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First' },
|
||||
'superset.chatbot',
|
||||
provider,
|
||||
),
|
||||
views.registerView(
|
||||
{ id: 'second.chatbot', name: 'Second' },
|
||||
'superset.chatbot',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getRegisteredViewIds('superset.chatbot')).toEqual([
|
||||
'first.chatbot',
|
||||
'second.chatbot',
|
||||
]);
|
||||
});
|
||||
|
||||
test('getRegisteredViewIds returns an empty array for an unused location', () => {
|
||||
expect(getRegisteredViewIds('superset.chatbot')).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -39,6 +39,27 @@ const viewRegistry: Map<
|
||||
|
||||
const locationIndex: Map<string, Set<string>> = new Map();
|
||||
|
||||
/**
|
||||
* Monotonic version of the view registry. Bumped on every registration or
|
||||
* disposal so consumers can re-derive state via React's `useSyncExternalStore`.
|
||||
*/
|
||||
let registryVersion = 0;
|
||||
const registrySubscribers = new Set<() => void>();
|
||||
|
||||
const notifyRegistry = () => {
|
||||
registryVersion += 1;
|
||||
registrySubscribers.forEach(fn => fn());
|
||||
};
|
||||
|
||||
export const subscribeToRegistry = (listener: () => void): (() => void) => {
|
||||
registrySubscribers.add(listener);
|
||||
return () => {
|
||||
registrySubscribers.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const getRegistryVersion = () => registryVersion;
|
||||
|
||||
const registerView: typeof viewsApi.registerView = (
|
||||
view: View,
|
||||
location: string,
|
||||
@@ -46,15 +67,24 @@ const registerView: typeof viewsApi.registerView = (
|
||||
): Disposable => {
|
||||
const { id } = view;
|
||||
|
||||
const previousLocation = viewRegistry.get(id)?.location;
|
||||
if (previousLocation && previousLocation !== location) {
|
||||
locationIndex.get(previousLocation)?.delete(id);
|
||||
}
|
||||
|
||||
viewRegistry.set(id, { view, location, provider });
|
||||
|
||||
const ids = locationIndex.get(location) ?? new Set();
|
||||
ids.add(id);
|
||||
locationIndex.set(location, ids);
|
||||
|
||||
notifyRegistry();
|
||||
|
||||
return new Disposable(() => {
|
||||
const registeredLocation = viewRegistry.get(id)?.location ?? location;
|
||||
viewRegistry.delete(id);
|
||||
locationIndex.get(location)?.delete(id);
|
||||
locationIndex.get(registeredLocation)?.delete(id);
|
||||
notifyRegistry();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -77,6 +107,28 @@ const getViews: typeof viewsApi.getViews = (
|
||||
.filter((c): c is View => !!c);
|
||||
};
|
||||
|
||||
/**
|
||||
* Host-internal: returns the provider for a registered view id at a location.
|
||||
* Not part of the public `@apache-superset/core` API — `getViews` stays
|
||||
* descriptor-only so extensions cannot render each other's views directly.
|
||||
*/
|
||||
export const getViewProvider = (
|
||||
location: string,
|
||||
id: string,
|
||||
): (() => ReactElement) | undefined => {
|
||||
const entry = viewRegistry.get(id);
|
||||
if (entry?.location !== location) {
|
||||
return undefined;
|
||||
}
|
||||
return entry.provider;
|
||||
};
|
||||
|
||||
/** Host-internal: view ids at a location in registration order. */
|
||||
export const getRegisteredViewIds = (location: string): string[] => {
|
||||
const ids = locationIndex.get(location);
|
||||
return ids ? Array.from(ids) : [];
|
||||
};
|
||||
|
||||
export const views: typeof viewsApi = {
|
||||
registerView,
|
||||
getViews,
|
||||
|
||||
@@ -597,6 +597,35 @@ test('should fave', async () => {
|
||||
expect(saveFaveStar).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// FaveStar.onClick passes the *prior* isStarred value to saveFaveStar — the
|
||||
// reducer flips it. So favoriting (unstarred → starred) sends `false`, and
|
||||
// unfavoriting (starred → unstarred) sends `true`.
|
||||
test('should call saveFaveStar with false when favoriting from the header', () => {
|
||||
setup();
|
||||
const header = screen.getByTestId('dashboard-header-container');
|
||||
|
||||
userEvent.click(within(header).getByRole('img', { name: 'unstarred' }));
|
||||
expect(saveFaveStar).toHaveBeenCalledTimes(1);
|
||||
expect(saveFaveStar).toHaveBeenCalledWith(
|
||||
initialState.dashboardInfo.id,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('should call saveFaveStar with true when unfavoriting from the header', () => {
|
||||
setup({
|
||||
dashboardState: { ...initialState.dashboardState, isStarred: true },
|
||||
});
|
||||
const header = screen.getByTestId('dashboard-header-container');
|
||||
|
||||
userEvent.click(within(header).getByRole('img', { name: 'starred' }));
|
||||
expect(saveFaveStar).toHaveBeenCalledTimes(1);
|
||||
expect(saveFaveStar).toHaveBeenCalledWith(
|
||||
initialState.dashboardInfo.id,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('should toggle the edit mode', () => {
|
||||
const canEditState = {
|
||||
dashboardInfo: {
|
||||
|
||||
@@ -18,7 +18,13 @@
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
createStore,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import reducerIndex from 'spec/helpers/reducerIndex';
|
||||
import {
|
||||
useDashboard,
|
||||
useDashboardCharts,
|
||||
@@ -27,7 +33,11 @@ import {
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import CrudThemeProvider from 'src/components/CrudThemeProvider';
|
||||
import { hydrateDashboard } from 'src/dashboard/actions/hydrate';
|
||||
import { clearDashboardHistory } from 'src/dashboard/actions/dashboardLayout';
|
||||
import {
|
||||
clearDashboardHistory,
|
||||
UPDATE_COMPONENTS,
|
||||
} from 'src/dashboard/actions/dashboardLayout';
|
||||
import { DASHBOARD_HEADER_ID } from 'src/dashboard/util/constants';
|
||||
import DashboardPage from './DashboardPage';
|
||||
|
||||
const mockTheme = {
|
||||
@@ -148,6 +158,9 @@ afterEach(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Tests assert against the global document.title and the unmount restore
|
||||
// effect can carry title state across tests, so reset it for isolation.
|
||||
document.title = '';
|
||||
mockUseDashboard.mockReturnValue({
|
||||
result: mockDashboard,
|
||||
error: null,
|
||||
@@ -233,6 +246,174 @@ test('uses theme from Redux dashboardInfo when it differs from API response (Pro
|
||||
);
|
||||
});
|
||||
|
||||
test('document.title tracks the live Redux dashboard title after a rename, not the stale API value', async () => {
|
||||
// Renaming a dashboard updates the live title in Redux
|
||||
// (dashboardLayout HEADER meta.text) and persists via an in-SPA save with
|
||||
// no full reload, so the useDashboard() API result stays stale. The browser
|
||||
// tab title must follow the live title, otherwise a newly created dashboard
|
||||
// keeps showing "[ untitled dashboard ]" after being renamed and saved.
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
<DashboardPage idOrSlug="1" />
|
||||
</Suspense>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
dashboardInfo: { id: 1, metadata: {} },
|
||||
dashboardState: { sliceIds: [] },
|
||||
dashboardLayout: {
|
||||
past: [],
|
||||
future: [],
|
||||
present: {
|
||||
[DASHBOARD_HEADER_ID]: {
|
||||
id: DASHBOARD_HEADER_ID,
|
||||
type: 'HEADER',
|
||||
meta: { text: 'Live Renamed Title' },
|
||||
},
|
||||
},
|
||||
},
|
||||
nativeFilters: { filters: {} },
|
||||
dataMask: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// API result (mockDashboard.dashboard_title) is 'Test Dashboard', but the
|
||||
// live title is 'Live Renamed Title' — the tab title must reflect the latter.
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe('Live Renamed Title');
|
||||
});
|
||||
});
|
||||
|
||||
test('document.title updates when the dashboard is renamed after mount', async () => {
|
||||
// The bug is a live rename: the title is edited in Redux after the page has
|
||||
// already mounted, so the tab title must react to the change rather than only
|
||||
// reflecting the title present at initial render.
|
||||
const store = createStore(
|
||||
{
|
||||
dashboardInfo: { id: 1, metadata: {} },
|
||||
dashboardState: { sliceIds: [] },
|
||||
dashboardLayout: {
|
||||
past: [],
|
||||
future: [],
|
||||
present: {
|
||||
[DASHBOARD_HEADER_ID]: {
|
||||
id: DASHBOARD_HEADER_ID,
|
||||
type: 'HEADER',
|
||||
meta: { text: 'Title At Mount' },
|
||||
},
|
||||
},
|
||||
},
|
||||
nativeFilters: { filters: {} },
|
||||
dataMask: {},
|
||||
},
|
||||
reducerIndex,
|
||||
);
|
||||
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
<DashboardPage idOrSlug="1" />
|
||||
</Suspense>,
|
||||
{ store, useRouter: true },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(document.title).toBe('Title At Mount'));
|
||||
|
||||
// Simulate the in-SPA rename mutating the live header title.
|
||||
store.dispatch({
|
||||
type: UPDATE_COMPONENTS,
|
||||
payload: {
|
||||
nextComponents: {
|
||||
[DASHBOARD_HEADER_ID]: {
|
||||
id: DASHBOARD_HEADER_ID,
|
||||
type: 'HEADER',
|
||||
meta: { text: 'Renamed After Mount' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(document.title).toBe('Renamed After Mount'));
|
||||
});
|
||||
|
||||
test('document.title uses the fresh API title during dashboard-to-dashboard navigation', async () => {
|
||||
// While switching dashboards in the SPA the component instance and Redux store
|
||||
// are reused, so the previous dashboard's layout (header title) lingers until
|
||||
// the new dashboard hydrates. The tab title must follow the newly loaded
|
||||
// dashboard's API title, not the stale live layout title.
|
||||
mockUseDashboard.mockReturnValue({
|
||||
result: { ...mockDashboard, id: 2, dashboard_title: 'Dashboard Two' },
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
<DashboardPage idOrSlug="2" />
|
||||
</Suspense>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
// dashboardInfo still describes the previously hydrated dashboard 1.
|
||||
dashboardInfo: { id: 1, metadata: {} },
|
||||
dashboardState: { sliceIds: [] },
|
||||
dashboardLayout: {
|
||||
past: [],
|
||||
future: [],
|
||||
present: {
|
||||
[DASHBOARD_HEADER_ID]: {
|
||||
id: DASHBOARD_HEADER_ID,
|
||||
type: 'HEADER',
|
||||
meta: { text: 'Dashboard One' },
|
||||
},
|
||||
},
|
||||
},
|
||||
nativeFilters: { filters: {} },
|
||||
dataMask: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(document.title).toBe('Dashboard Two'));
|
||||
});
|
||||
|
||||
test('document.title falls back to the API dashboard_title before the layout is hydrated', async () => {
|
||||
// Before hydration there is no HEADER component in the layout, so the tab
|
||||
// title should still come from the dashboard API response.
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
<DashboardPage idOrSlug="1" />
|
||||
</Suspense>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
dashboardInfo: { id: 1, metadata: {} },
|
||||
dashboardState: { sliceIds: [] },
|
||||
nativeFilters: { filters: {} },
|
||||
dataMask: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe('Test Dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
test('passes null theme when Redux dashboardInfo.theme is explicitly null (theme removed)', async () => {
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
@@ -285,7 +466,9 @@ test('clears undo history after hydrating the dashboard', async () => {
|
||||
|
||||
expect(hydrateDashboard).toHaveBeenCalled();
|
||||
expect(clearDashboardHistory).toHaveBeenCalled();
|
||||
const hydrateOrder = (hydrateDashboard as jest.Mock).mock.invocationCallOrder[0];
|
||||
const clearOrder = (clearDashboardHistory as jest.Mock).mock.invocationCallOrder[0];
|
||||
const hydrateOrder = (hydrateDashboard as jest.Mock).mock
|
||||
.invocationCallOrder[0];
|
||||
const clearOrder = (clearDashboardHistory as jest.Mock).mock
|
||||
.invocationCallOrder[0];
|
||||
expect(clearOrder).toBeGreaterThan(hydrateOrder);
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ import { LocalStorageKeys, setItem } from 'src/utils/localStorageHelpers';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { setDatasetsStatus } from 'src/dashboard/actions/dashboardState';
|
||||
import { DASHBOARD_HEADER_ID } from 'src/dashboard/util/constants';
|
||||
import {
|
||||
getFilterValue,
|
||||
getPermalinkValue,
|
||||
@@ -152,6 +153,23 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
const readyToRender = Boolean(dashboard && charts);
|
||||
const { dashboard_title, id = 0 } = dashboard || {};
|
||||
|
||||
// The live title is edited in Redux and persisted via an in-SPA save with no
|
||||
// full reload, so the useDashboard() API result can be stale. Track the live
|
||||
// title so the browser tab stays in sync after a rename.
|
||||
const liveDashboardTitle = useSelector<RootState, string | undefined>(
|
||||
state => state.dashboardLayout?.present?.[DASHBOARD_HEADER_ID]?.meta?.text,
|
||||
);
|
||||
// Only trust the live layout title once the layout belongs to the dashboard
|
||||
// being shown. During SPA dashboard-to-dashboard navigation the previous
|
||||
// dashboard's layout lingers until the new one hydrates, so fall back to the
|
||||
// freshly fetched API title until the hydrated dashboard matches.
|
||||
const hydratedDashboardId = useSelector<RootState, number | undefined>(
|
||||
state => state.dashboardInfo?.id,
|
||||
);
|
||||
const pageTitle =
|
||||
(hydratedDashboardId === id ? liveDashboardTitle : undefined) ||
|
||||
dashboard_title;
|
||||
|
||||
// Get CSS from dashboardInfo (unified properties location)
|
||||
const css =
|
||||
useSelector((state: RootState) => state.dashboardInfo.css) ||
|
||||
@@ -303,10 +321,10 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
|
||||
// Update document title when dashboard title changes
|
||||
useEffect(() => {
|
||||
if (dashboard_title) {
|
||||
document.title = dashboard_title;
|
||||
if (pageTitle) {
|
||||
document.title = pageTitle;
|
||||
}
|
||||
}, [dashboard_title]);
|
||||
}, [pageTitle]);
|
||||
|
||||
// Restore original title on unmount
|
||||
useEffect(
|
||||
|
||||
@@ -26,9 +26,10 @@ import { DynamicPluginProvider } from 'src/components';
|
||||
import { EmbeddedUiConfigProvider } from 'src/components/UiConfigContext';
|
||||
import { SupersetThemeProvider } from 'src/theme/ThemeProvider';
|
||||
import { ThemeController } from 'src/theme/ThemeController';
|
||||
import { type ThemeStorage, ThemeMode } from '@apache-superset/core/theme';
|
||||
import { type ThemeStorage } from '@apache-superset/core/theme';
|
||||
import { store } from 'src/views/store';
|
||||
import querystring from 'query-string';
|
||||
import { getInitialThemeMode } from './getInitialThemeMode';
|
||||
|
||||
/**
|
||||
* In-memory implementation of ThemeStorage interface for embedded contexts.
|
||||
@@ -52,7 +53,7 @@ class ThemeMemoryStorageAdapter implements ThemeStorage {
|
||||
|
||||
const themeController = new ThemeController({
|
||||
storage: new ThemeMemoryStorageAdapter(),
|
||||
initialMode: ThemeMode.DEFAULT,
|
||||
initialMode: getInitialThemeMode(),
|
||||
});
|
||||
|
||||
export const getThemeController = (): ThemeController => themeController;
|
||||
|
||||
66
superset-frontend/src/embedded/getInitialThemeMode.test.ts
Normal file
66
superset-frontend/src/embedded/getInitialThemeMode.test.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.
|
||||
*/
|
||||
import { ThemeMode } from '@apache-superset/core/theme';
|
||||
import { getInitialThemeMode } from './getInitialThemeMode';
|
||||
|
||||
let locationSpy: jest.SpyInstance | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
locationSpy?.mockRestore();
|
||||
});
|
||||
|
||||
test('returns ThemeMode.DARK when ?themeMode=dark', () => {
|
||||
locationSpy = jest.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '?themeMode=dark',
|
||||
} as Location);
|
||||
expect(getInitialThemeMode()).toBe(ThemeMode.DARK);
|
||||
});
|
||||
|
||||
test('returns ThemeMode.SYSTEM when ?themeMode=system', () => {
|
||||
locationSpy = jest.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '?themeMode=system',
|
||||
} as Location);
|
||||
expect(getInitialThemeMode()).toBe(ThemeMode.SYSTEM);
|
||||
});
|
||||
|
||||
test('returns ThemeMode.DEFAULT when ?themeMode=light', () => {
|
||||
locationSpy = jest.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '?themeMode=light',
|
||||
} as Location);
|
||||
expect(getInitialThemeMode()).toBe(ThemeMode.DEFAULT);
|
||||
});
|
||||
|
||||
test('returns ThemeMode.DEFAULT when no themeMode param', () => {
|
||||
locationSpy = jest.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '',
|
||||
} as Location);
|
||||
expect(getInitialThemeMode()).toBe(ThemeMode.DEFAULT);
|
||||
});
|
||||
|
||||
test('returns ThemeMode.DEFAULT for an unrecognised value', () => {
|
||||
locationSpy = jest.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '?themeMode=invalid',
|
||||
} as Location);
|
||||
expect(getInitialThemeMode()).toBe(ThemeMode.DEFAULT);
|
||||
});
|
||||
35
superset-frontend/src/embedded/getInitialThemeMode.ts
Normal file
35
superset-frontend/src/embedded/getInitialThemeMode.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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 { ThemeMode } from '@apache-superset/core/theme';
|
||||
|
||||
/**
|
||||
* Reads the `?themeMode=` URL parameter from the iframe URL and returns
|
||||
* the corresponding ThemeMode. Falls back to ThemeMode.DEFAULT when the
|
||||
* param is absent or unrecognised.
|
||||
*
|
||||
* Host apps set this via `dashboardUiConfig.urlParams.themeMode` in the
|
||||
* embed SDK, which forwards it to the iframe URL automatically.
|
||||
*/
|
||||
export function getInitialThemeMode(): ThemeMode {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const themeMode = params.get('themeMode');
|
||||
if (themeMode === 'dark') return ThemeMode.DARK;
|
||||
if (themeMode === 'system') return ThemeMode.SYSTEM;
|
||||
return ThemeMode.DEFAULT;
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
import ExtensionsList from './ExtensionsList';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
beforeAll(() => fetchMock.unmockGlobal());
|
||||
|
||||
// Mock initial state for the store
|
||||
const mockInitialState = {
|
||||
extensions: {
|
||||
loading: false,
|
||||
resourceCount: 2,
|
||||
resourceCollection: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Extension 1',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Test Extension 2',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
bulkSelectEnabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
addDangerToast: jest.fn(),
|
||||
addSuccessToast: jest.fn(),
|
||||
};
|
||||
|
||||
const renderWithStore = (props = {}) =>
|
||||
render(<ExtensionsList {...defaultProps} {...props} />, {
|
||||
useRedux: true,
|
||||
useQueryParams: true,
|
||||
useRouter: true,
|
||||
useTheme: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
test('renders extensions list with basic structure', async () => {
|
||||
renderWithStore();
|
||||
|
||||
// Check that the component renders
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('displays extension names in the list', async () => {
|
||||
renderWithStore();
|
||||
|
||||
await waitFor(() => {
|
||||
// These texts should appear somewhere in the rendered component
|
||||
expect(document.body).toHaveTextContent(/Extensions/);
|
||||
});
|
||||
});
|
||||
|
||||
test('calls toast functions when provided', () => {
|
||||
const addDangerToast = jest.fn();
|
||||
const addSuccessToast = jest.fn();
|
||||
|
||||
renderWithStore({
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
});
|
||||
|
||||
// The component should accept these props without error
|
||||
expect(addDangerToast).toBeDefined();
|
||||
expect(addSuccessToast).toBeDefined();
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { FunctionComponent, useMemo } from 'react';
|
||||
import { useListViewResource } from 'src/views/CRUD/hooks';
|
||||
import { ListView } from 'src/components';
|
||||
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
type Extension = {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
interface ExtensionsListProps {
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
}) => {
|
||||
const {
|
||||
state: { loading, resourceCount, resourceCollection },
|
||||
fetchData,
|
||||
refreshData,
|
||||
} = useListViewResource<Extension>(
|
||||
'extensions',
|
||||
t('Extensions'),
|
||||
addDangerToast,
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: t('Name'),
|
||||
accessor: 'name',
|
||||
size: 'lg',
|
||||
id: 'name',
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { name },
|
||||
},
|
||||
}: any) => name,
|
||||
},
|
||||
],
|
||||
[loading], // We need to monitor loading to avoid stale state in actions
|
||||
);
|
||||
|
||||
const menuData: SubMenuProps = {
|
||||
activeChild: 'Extensions',
|
||||
name: t('Extensions'),
|
||||
buttons: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubMenu {...menuData} />
|
||||
<ListView<Extension>
|
||||
columns={columns}
|
||||
count={resourceCount}
|
||||
data={resourceCollection}
|
||||
initialSort={[{ id: 'name', desc: false }]}
|
||||
pageSize={PAGE_SIZE}
|
||||
fetchData={fetchData}
|
||||
loading={loading}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
refreshData={refreshData}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default withToasts(ExtensionsList);
|
||||
@@ -29,15 +29,20 @@ function createMockExtension(overrides: Partial<Extension> = {}): Extension {
|
||||
name: 'Test Extension',
|
||||
description: 'A test extension',
|
||||
version: '1.0.0',
|
||||
dependencies: [],
|
||||
remoteEntry: '',
|
||||
extensionDependencies: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(ExtensionsLoader as any).instance = undefined;
|
||||
// Minimal host registry surface the loader wraps during module evaluation.
|
||||
(window as any).superset = {
|
||||
commands: { registerCommand: jest.fn() },
|
||||
menus: { registerMenuItem: jest.fn() },
|
||||
editors: { registerEditor: jest.fn() },
|
||||
views: { registerView: jest.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
test('creates a singleton instance', () => {
|
||||
@@ -142,3 +147,59 @@ test('logs error when initializeExtensions fails', async () => {
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
/**
|
||||
* Stubs the module-federation machinery `loadModule` depends on so a fake
|
||||
* extension entry module (its `./index` factory) can be loaded in jsdom.
|
||||
* Returns a cleanup function that restores the patched globals.
|
||||
*/
|
||||
function mockRemoteModule(containerName: string, factory: () => unknown) {
|
||||
const appendChildSpy = jest
|
||||
.spyOn(document.head, 'appendChild')
|
||||
.mockImplementation((element: Node) => {
|
||||
if (element instanceof HTMLScriptElement && element.onload) {
|
||||
setTimeout(() => (element.onload as any)(new Event('load')), 0);
|
||||
}
|
||||
return element;
|
||||
});
|
||||
|
||||
(global as any).__webpack_init_sharing__ = jest
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
(global as any).__webpack_share_scopes__ = { default: {} };
|
||||
(window as any)[containerName] = {
|
||||
init: jest.fn().mockResolvedValue(undefined),
|
||||
get: jest.fn().mockResolvedValue(factory),
|
||||
};
|
||||
|
||||
return () => {
|
||||
appendChildSpy.mockRestore();
|
||||
delete (global as any).__webpack_init_sharing__;
|
||||
delete (global as any).__webpack_share_scopes__;
|
||||
delete (window as any)[containerName];
|
||||
};
|
||||
}
|
||||
|
||||
const remoteExtension = (overrides: Partial<Extension> = {}) =>
|
||||
createMockExtension({
|
||||
id: 'remote-ext',
|
||||
remoteEntry: 'http://example/remoteEntry.js',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('runs activate(context) hook for modern-style extensions', async () => {
|
||||
const loader = ExtensionsLoader.getInstance();
|
||||
const activate = jest.fn().mockResolvedValue(undefined);
|
||||
const factory = () => ({ activate });
|
||||
const cleanup = mockRemoteModule('remote-ext', factory);
|
||||
|
||||
await loader.initializeExtension(remoteExtension());
|
||||
|
||||
expect(activate).toHaveBeenCalledTimes(1);
|
||||
// The context object passed to activate must have a subscriptions array.
|
||||
expect(activate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ subscriptions: expect.any(Array) }),
|
||||
);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
@@ -17,10 +17,17 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import type { common as core } from '@apache-superset/core';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { store } from 'src/views/store';
|
||||
// Side-effect import: brings the `window.superset` global augmentation into scope.
|
||||
import 'src/extensions/supersetGlobal';
|
||||
|
||||
type Extension = core.Extension;
|
||||
type ExtensionContext = core.ExtensionContext;
|
||||
type ExtensionModule = core.ExtensionModule;
|
||||
|
||||
/**
|
||||
* Loads extension modules via webpack module federation.
|
||||
@@ -81,7 +88,8 @@ class ExtensionsLoader {
|
||||
|
||||
/**
|
||||
* Initializes a single extension.
|
||||
* If the extension has a remote entry, loads the module (which triggers
|
||||
* If the extension has a remote entry, loads the module and runs its
|
||||
* `activate(context)` hook (or, for legacy extensions, its top-level
|
||||
* side-effect registrations for commands, views, menus, and editors).
|
||||
* @param extension The extension to initialize.
|
||||
*/
|
||||
@@ -96,12 +104,15 @@ class ExtensionsLoader {
|
||||
`Failed to initialize extension ${extension.name}\n`,
|
||||
error,
|
||||
);
|
||||
store.dispatch(
|
||||
addDangerToast(t('Extension "%s" failed to load.', extension.name)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a single extension module via webpack module federation.
|
||||
* The module's top-level side effects fire contribution registrations.
|
||||
* Loads a single extension module via webpack module federation and runs its
|
||||
* `activate(context)` hook.
|
||||
* @param extension The extension to load.
|
||||
*/
|
||||
private async loadModule(extension: Extension): Promise<void> {
|
||||
@@ -149,8 +160,21 @@ class ExtensionsLoader {
|
||||
await container.init(__webpack_share_scopes__.default);
|
||||
|
||||
const factory = await container.get('./index');
|
||||
// Execute the module factory - side effects fire registrations
|
||||
factory();
|
||||
|
||||
// `context.subscriptions` is provided for extensions to push their
|
||||
// Disposables into. The host does not dispose them (lifecycle management is
|
||||
// deferred); extensions own the array for as long as they are active.
|
||||
const context: ExtensionContext = { subscriptions: [] };
|
||||
|
||||
// Evaluate the module factory. Extensions may register contributions as
|
||||
// top-level side effects here, or return a module exposing `activate`.
|
||||
const module = factory() as ExtensionModule | undefined;
|
||||
|
||||
// Preferred path: hand the extension its context so it can track every
|
||||
// registration it makes, synchronous or asynchronous.
|
||||
if (typeof module?.activate === 'function') {
|
||||
await module.activate(context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,6 +72,7 @@ afterEach(() => {
|
||||
test('renders without crashing', () => {
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -88,6 +89,7 @@ test('sets up global superset object when user is logged in', async () => {
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -109,6 +111,7 @@ test('sets up global superset object when user is logged in', async () => {
|
||||
test('does not set up global superset object when user is not logged in', async () => {
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialStateNoUser,
|
||||
});
|
||||
|
||||
@@ -127,6 +130,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -144,6 +148,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
|
||||
test('does not initialize ExtensionsLoader when user is not logged in', async () => {
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialStateNoUser,
|
||||
});
|
||||
|
||||
@@ -169,6 +174,7 @@ test('only initializes once even with multiple renders', async () => {
|
||||
|
||||
const { rerender } = render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -205,6 +211,7 @@ test('initializes ExtensionsLoader when EnableExtensions feature flag is enabled
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -234,6 +241,7 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -268,6 +276,7 @@ test('continues rendering children even when ExtensionsLoader initialization fai
|
||||
</ExtensionsStartup>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -16,48 +16,66 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
import * as supersetCore from '@apache-superset/core';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import {
|
||||
authentication,
|
||||
core,
|
||||
commands,
|
||||
dashboard,
|
||||
dataset,
|
||||
editors,
|
||||
explore,
|
||||
extensions,
|
||||
menus,
|
||||
navigation,
|
||||
sqlLab,
|
||||
views,
|
||||
} from 'src/core';
|
||||
import { notifyPageChange } from 'src/core/navigation';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from 'src/views/store';
|
||||
import ExtensionsLoader from './ExtensionsLoader';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
superset: {
|
||||
authentication: typeof authentication;
|
||||
core: typeof core;
|
||||
commands: typeof commands;
|
||||
editors: typeof editors;
|
||||
extensions: typeof extensions;
|
||||
menus: typeof menus;
|
||||
sqlLab: typeof sqlLab;
|
||||
views: typeof views;
|
||||
};
|
||||
}
|
||||
}
|
||||
// Side-effect import: brings the `window.superset` global augmentation into scope.
|
||||
import 'src/extensions/supersetGlobal';
|
||||
|
||||
const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const location = useLocation();
|
||||
const prevPathname = useRef<string | null>(null);
|
||||
|
||||
const userId = useSelector<RootState, number | undefined>(
|
||||
({ user }) => user.userId,
|
||||
);
|
||||
|
||||
// Notify the navigation namespace on every route change.
|
||||
useEffect(() => {
|
||||
if (prevPathname.current !== location.pathname) {
|
||||
prevPathname.current = location.pathname;
|
||||
notifyPageChange(location.pathname);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
// Log unhandled rejections that may originate from extension code.
|
||||
// Registered once for the lifetime of the app; does not suppress the
|
||||
// browser's default error surfacing so host error reporting is unaffected.
|
||||
useEffect(() => {
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
logging.error('[extensions] Unhandled rejection:', event.reason);
|
||||
};
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'unhandledrejection',
|
||||
handleUnhandledRejection,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized) return;
|
||||
|
||||
@@ -67,27 +85,33 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Provide the implementations for @apache-superset/core
|
||||
// Provide the implementations for @apache-superset/core.
|
||||
// Namespaces are listed explicitly — do not spread the core package here,
|
||||
// as that would leak un-contracted symbols onto window.superset.
|
||||
window.superset = {
|
||||
...supersetCore,
|
||||
authentication,
|
||||
core,
|
||||
commands,
|
||||
dashboard,
|
||||
dataset,
|
||||
editors,
|
||||
explore,
|
||||
extensions,
|
||||
menus,
|
||||
navigation,
|
||||
sqlLab,
|
||||
views,
|
||||
};
|
||||
|
||||
const setup = async () => {
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
await ExtensionsLoader.getInstance().initializeExtensions();
|
||||
}
|
||||
setInitialized(true);
|
||||
};
|
||||
// Render the host immediately; extension bundles load in the background.
|
||||
// ChatbotMount re-resolves reactively once the chatbot extension registers
|
||||
// (via subscribeToRegistry / getRegistryVersion), so the bubble appears
|
||||
// without blocking the UI.
|
||||
setInitialized(true);
|
||||
|
||||
setup();
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
ExtensionsLoader.getInstance().initializeExtensions();
|
||||
}
|
||||
}, [initialized, userId]);
|
||||
|
||||
if (!initialized) {
|
||||
|
||||
64
superset-frontend/src/extensions/supersetGlobal.ts
Normal file
64
superset-frontend/src/extensions/supersetGlobal.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Global `window.superset` type augmentation.
|
||||
*
|
||||
* Lives in its own module (rather than inline in ExtensionsStartup) so every
|
||||
* file that reads or writes `window.superset` — notably ExtensionsLoader —
|
||||
* sees the type regardless of how files are batched during compilation. Both
|
||||
* the startup component and the loader import this module for its side effect.
|
||||
*/
|
||||
|
||||
import type {
|
||||
authentication,
|
||||
commands,
|
||||
core,
|
||||
dashboard,
|
||||
dataset,
|
||||
editors,
|
||||
explore,
|
||||
extensions,
|
||||
menus,
|
||||
navigation,
|
||||
sqlLab,
|
||||
views,
|
||||
} from 'src/core';
|
||||
|
||||
/** The host namespaces exposed to extensions on `window.superset`. */
|
||||
export interface SupersetGlobal {
|
||||
authentication: typeof authentication;
|
||||
core: typeof core;
|
||||
commands: typeof commands;
|
||||
dashboard: typeof dashboard;
|
||||
dataset: typeof dataset;
|
||||
editors: typeof editors;
|
||||
explore: typeof explore;
|
||||
extensions: typeof extensions;
|
||||
menus: typeof menus;
|
||||
navigation: typeof navigation;
|
||||
sqlLab: typeof sqlLab;
|
||||
views: typeof views;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
superset: SupersetGlobal;
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,18 @@ import { render, screen } from 'spec/helpers/testing-library';
|
||||
import EditDataset from './index';
|
||||
|
||||
const DATASET_ENDPOINT = 'glob:*api/v1/dataset/1/related_objects';
|
||||
// EditPage also fetches the dataset entity itself to publish the `dataset`
|
||||
// extension-namespace context (setCurrentDataset).
|
||||
const DATASET_RESOURCE_ENDPOINT = 'glob:*api/v1/dataset/1';
|
||||
|
||||
const mockedProps = {
|
||||
id: '1',
|
||||
};
|
||||
|
||||
fetchMock.get(DATASET_ENDPOINT, { charts: { results: [], count: 2 } });
|
||||
fetchMock.get(DATASET_RESOURCE_ENDPOINT, {
|
||||
result: { id: 1, table_name: 'test_table', schema: 'public' },
|
||||
});
|
||||
|
||||
test('should render edit dataset view with tabs', async () => {
|
||||
render(<EditDataset {...mockedProps} />);
|
||||
|
||||
@@ -16,9 +16,12 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import useGetDatasetRelatedCounts from 'src/features/datasets/hooks/useGetDatasetRelatedCounts';
|
||||
import { useSingleViewResource } from 'src/views/CRUD/hooks';
|
||||
import { setCurrentDataset } from 'src/core/dataset';
|
||||
import { Badge } from '@superset-ui/core/components';
|
||||
import Tabs from '@superset-ui/core/components/Tabs';
|
||||
|
||||
@@ -47,6 +50,13 @@ interface EditPageProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// Stable no-op error handler so `useSingleViewResource`'s `fetchResource`
|
||||
// keeps a stable identity across renders (it lists the handler in its deps).
|
||||
// An inline handler would change every render and re-trigger the fetch effect,
|
||||
// causing an update loop. Fetch failure is non-fatal here — the dataset
|
||||
// context simply stays empty.
|
||||
const noopErrorHandler = () => {};
|
||||
|
||||
const TRANSLATIONS = {
|
||||
USAGE_TEXT: t('Usage'),
|
||||
COLUMNS_TEXT: t('Columns'),
|
||||
@@ -62,6 +72,45 @@ const TABS_KEYS = {
|
||||
const EditPage = ({ id }: EditPageProps) => {
|
||||
const { usageCount } = useGetDatasetRelatedCounts(id);
|
||||
|
||||
// Publish the focused dataset to the `dataset` extension namespace so chatbot
|
||||
// extensions can read which dataset the user is editing. Cleared on unmount.
|
||||
const {
|
||||
state: { resource: datasetResource },
|
||||
fetchResource,
|
||||
} = useSingleViewResource<{
|
||||
id: number;
|
||||
table_name?: string;
|
||||
schema?: string | null;
|
||||
catalog?: string | null;
|
||||
sql?: string | null;
|
||||
is_sqllab_view?: boolean;
|
||||
database?: { database_name?: string };
|
||||
}>('dataset', t('dataset'), noopErrorHandler);
|
||||
|
||||
useEffect(() => {
|
||||
const datasetId = Number(id);
|
||||
if (!Number.isNaN(datasetId)) {
|
||||
fetchResource(datasetId);
|
||||
}
|
||||
// `fetchResource` is stable (noopErrorHandler keeps its identity fixed);
|
||||
// fetch only when the id changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!datasetResource) return undefined;
|
||||
setCurrentDataset({
|
||||
datasetId: datasetResource.id,
|
||||
datasetName: datasetResource.table_name ?? String(datasetResource.id),
|
||||
schema: datasetResource.schema ?? null,
|
||||
catalog: datasetResource.catalog ?? null,
|
||||
databaseName: datasetResource.database?.database_name ?? null,
|
||||
isVirtual:
|
||||
Boolean(datasetResource.sql) || !!datasetResource.is_sqllab_view,
|
||||
});
|
||||
return () => setCurrentDataset(undefined);
|
||||
}, [datasetResource]);
|
||||
|
||||
const usageTab = (
|
||||
<TabStyles>
|
||||
<span>{TRANSLATIONS.USAGE_TEXT}</span>
|
||||
|
||||
@@ -252,9 +252,7 @@ describe('RoleListEditModal', () => {
|
||||
const mockGet = SupersetClient.get as jest.Mock;
|
||||
mockGet.mockImplementation(({ endpoint }) => {
|
||||
if (
|
||||
endpoint?.includes(
|
||||
`/api/v1/security/roles/${mockRole.id}/permissions/`,
|
||||
)
|
||||
endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`)
|
||||
) {
|
||||
// Only return permission id=10, not id=20
|
||||
return Promise.resolve({
|
||||
@@ -298,9 +296,7 @@ describe('RoleListEditModal', () => {
|
||||
const mockGet = SupersetClient.get as jest.Mock;
|
||||
mockGet.mockImplementation(({ endpoint }) => {
|
||||
if (
|
||||
endpoint?.includes(
|
||||
`/api/v1/security/roles/${mockRole.id}/permissions/`,
|
||||
)
|
||||
endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`)
|
||||
) {
|
||||
return Promise.reject(new Error('network error'));
|
||||
}
|
||||
@@ -371,7 +367,9 @@ describe('RoleListEditModal', () => {
|
||||
};
|
||||
|
||||
mockGet.mockImplementation(({ endpoint }) => {
|
||||
if (endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)) {
|
||||
if (
|
||||
endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)
|
||||
) {
|
||||
return Promise.resolve({
|
||||
json: {
|
||||
result: roleA.permission_ids.map(pid => ({
|
||||
@@ -382,7 +380,9 @@ describe('RoleListEditModal', () => {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)) {
|
||||
if (
|
||||
endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)
|
||||
) {
|
||||
return Promise.resolve({
|
||||
json: {
|
||||
result: roleB.permission_ids.map(pid => ({
|
||||
|
||||
@@ -33,7 +33,12 @@ import { ensureAppRoot } from '../utils/pathUtils';
|
||||
import type { DashboardInfo, DashboardLayoutState } from '../dashboard/types';
|
||||
import type { QueryEditor } from '../SqlLab/types';
|
||||
|
||||
type LogEventSource = 'dashboard' | 'embedded_dashboard' | 'explore' | 'sqlLab' | 'slice';
|
||||
type LogEventSource =
|
||||
| 'dashboard'
|
||||
| 'embedded_dashboard'
|
||||
| 'explore'
|
||||
| 'sqlLab'
|
||||
| 'slice';
|
||||
|
||||
interface LogEventData {
|
||||
source?: LogEventSource;
|
||||
|
||||
@@ -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 ChatbotMount from 'src/components/ChatbotMount';
|
||||
import { RootContextProviders } from './RootContextProviders';
|
||||
import { ScrollToTop } from './ScrollToTop';
|
||||
|
||||
@@ -112,6 +114,13 @@ const App = () => (
|
||||
</Route>
|
||||
))}
|
||||
</Switch>
|
||||
{/*
|
||||
The singleton chatbot bubble. Rendered as a sibling of the route
|
||||
Switch — inside ExtensionsStartup so chatbot extensions have been
|
||||
loaded and registered, but outside the Switch so the bubble persists
|
||||
across route changes (SIP §3.2).
|
||||
*/}
|
||||
{isFeatureEnabled(FeatureFlag.EnableExtensions) && <ChatbotMount />}
|
||||
</ExtensionsStartup>
|
||||
<ToastContainer />
|
||||
</RootContextProviders>
|
||||
|
||||
31
superset-frontend/src/views/contributions.ts
Normal file
31
superset-frontend/src/views/contributions.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
/**
|
||||
* View locations for app-shell extension integration.
|
||||
*
|
||||
* These define locations that persist across all routes, mirroring the `app`
|
||||
* scope of the `ViewContributions` manifest schema.
|
||||
*/
|
||||
export const AppViewLocations = {
|
||||
app: {
|
||||
chatbot: 'superset.chatbot',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const CHATBOT_LOCATION = AppViewLocations.app.chatbot;
|
||||
@@ -128,10 +128,6 @@ const Tags = lazy(
|
||||
() => import(/* webpackChunkName: "Tags" */ 'src/pages/Tags'),
|
||||
);
|
||||
|
||||
const Extensions = lazy(
|
||||
() => import(/* webpackChunkName: "Tags" */ 'src/extensions/ExtensionsList'),
|
||||
);
|
||||
|
||||
const RowLevelSecurityList = lazy(
|
||||
() =>
|
||||
import(
|
||||
@@ -363,13 +359,6 @@ if (isAdmin) {
|
||||
Component: GroupsList,
|
||||
},
|
||||
);
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
routes.push({
|
||||
path: '/extensions/list/',
|
||||
Component: Extensions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (authRegistrationEnabled) {
|
||||
|
||||
@@ -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
|
||||
|
||||
30
superset-websocket/package-lock.json
generated
30
superset-websocket/package-lock.json
generated
@@ -27,7 +27,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.0",
|
||||
"@typescript-eslint/parser": "^8.60.0",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"globals": "^17.6.0",
|
||||
@@ -851,9 +851,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz",
|
||||
"integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==",
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz",
|
||||
"integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -2795,9 +2795,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz",
|
||||
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz",
|
||||
"integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2806,7 +2806,7 @@
|
||||
"@eslint/config-array": "^0.23.5",
|
||||
"@eslint/config-helpers": "^0.6.0",
|
||||
"@eslint/core": "^1.2.1",
|
||||
"@eslint/plugin-kit": "^0.7.1",
|
||||
"@eslint/plugin-kit": "^0.7.2",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
@@ -7100,9 +7100,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"@eslint/plugin-kit": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz",
|
||||
"integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==",
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz",
|
||||
"integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint/core": "^1.2.1",
|
||||
@@ -8567,9 +8567,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"eslint": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz",
|
||||
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz",
|
||||
"integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
@@ -8577,7 +8577,7 @@
|
||||
"@eslint/config-array": "^0.23.5",
|
||||
"@eslint/config-helpers": "^0.6.0",
|
||||
"@eslint/core": "^1.2.1",
|
||||
"@eslint/plugin-kit": "^0.7.1",
|
||||
"@eslint/plugin-kit": "^0.7.2",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.0",
|
||||
"@typescript-eslint/parser": "^8.60.0",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"globals": "^17.6.0",
|
||||
|
||||
@@ -18,6 +18,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
import jwt
|
||||
@@ -112,6 +113,7 @@ class AsyncQueryManager:
|
||||
self._jwt_cookie_domain: Optional[str]
|
||||
self._jwt_cookie_samesite: Optional[Literal["None", "Lax", "Strict"]] = None
|
||||
self._jwt_secret: str
|
||||
self._jwt_expiration_seconds: int = 0
|
||||
self._load_chart_data_into_cache_job: Any = None
|
||||
# pylint: disable=invalid-name
|
||||
self._load_explore_json_into_cache_job: Any = None
|
||||
@@ -147,6 +149,9 @@ class AsyncQueryManager:
|
||||
]
|
||||
self._jwt_cookie_domain = app.config["GLOBAL_ASYNC_QUERIES_JWT_COOKIE_DOMAIN"]
|
||||
self._jwt_secret = app.config["GLOBAL_ASYNC_QUERIES_JWT_SECRET"]
|
||||
self._jwt_expiration_seconds = app.config[
|
||||
"GLOBAL_ASYNC_QUERIES_JWT_EXPIRATION_SECONDS"
|
||||
]
|
||||
|
||||
if app.config["GLOBAL_ASYNC_QUERIES_REGISTER_REQUEST_HANDLERS"]:
|
||||
self.register_request_handlers(app)
|
||||
@@ -178,8 +183,13 @@ class AsyncQueryManager:
|
||||
session["async_user_id"] = user_id
|
||||
|
||||
sub = str(user_id) if user_id else None
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
token = jwt.encode(
|
||||
{"channel": async_channel_id, "sub": sub},
|
||||
{
|
||||
"channel": async_channel_id,
|
||||
"sub": sub,
|
||||
"exp": now + timedelta(seconds=self._jwt_expiration_seconds),
|
||||
},
|
||||
self._jwt_secret,
|
||||
algorithm="HS256",
|
||||
)
|
||||
@@ -191,6 +201,7 @@ class AsyncQueryManager:
|
||||
secure=self._jwt_cookie_secure,
|
||||
domain=self._jwt_cookie_domain,
|
||||
samesite=self._jwt_cookie_samesite,
|
||||
max_age=self._jwt_expiration_seconds,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -29,6 +29,7 @@ from superset.commands.dashboard.exceptions import (
|
||||
)
|
||||
from superset.commands.utils import populate_roles
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.utils import json
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -41,7 +42,17 @@ class CreateDashboardCommand(CreateMixin, BaseCommand):
|
||||
@transaction(on_error=partial(on_error, reraise=DashboardCreateFailedError))
|
||||
def run(self) -> Model:
|
||||
self.validate()
|
||||
return DashboardDAO.create(attributes=self._properties)
|
||||
dashboard = DashboardDAO.create(attributes=self._properties)
|
||||
# Link charts referenced in the layout to the dashboard so that
|
||||
# ``dashboard.slices`` is populated, mirroring the update path. Without
|
||||
# this, charts created through the REST API render with no definition
|
||||
# until the dashboard is edited and re-saved in the UI (see #32966).
|
||||
if json_metadata := self._properties.get("json_metadata"):
|
||||
DashboardDAO.set_dash_metadata(
|
||||
dashboard,
|
||||
data=json.loads(json_metadata),
|
||||
)
|
||||
return dashboard
|
||||
|
||||
def validate(self) -> None:
|
||||
exceptions: list[ValidationError] = []
|
||||
|
||||
@@ -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:
|
||||
|
||||
16
superset/commands/extension/__init__.py
Normal file
16
superset/commands/extension/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
16
superset/commands/extension/settings/__init__.py
Normal file
16
superset/commands/extension/settings/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
29
superset/commands/extension/settings/get.py
Normal file
29
superset/commands/extension/settings/get.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from typing import Any
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.daos.extension import get_extension_settings
|
||||
|
||||
|
||||
class GetExtensionSettingsCommand(BaseCommand):
|
||||
def run(self) -> dict[str, Any]:
|
||||
self.validate()
|
||||
return get_extension_settings()
|
||||
|
||||
def validate(self) -> None:
|
||||
return None
|
||||
@@ -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
|
||||
@@ -168,6 +172,21 @@ NATIVE_FILTER_DEFAULT_ROW_LIMIT = 1000
|
||||
# max rows retrieved by filter select auto complete
|
||||
FILTER_SELECT_ROW_LIMIT = 10000
|
||||
|
||||
# Upper bound on the number of time-shift comparisons a single chart may request.
|
||||
# Each comparison spawns an additional query, so this caps the work amplification
|
||||
# from a single chart request while still allowing generous normal use.
|
||||
VIZ_TIME_COMPARE_MAX = 50
|
||||
|
||||
# Upper bound on the number of sub-slices a deck.gl multi-layer chart may
|
||||
# aggregate. Each sub-slice issues its own query, so this caps the work
|
||||
# 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
|
||||
@@ -1121,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
|
||||
# ---------------------------------------------------
|
||||
@@ -2345,7 +2370,10 @@ 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())
|
||||
GLOBAL_ASYNC_QUERIES_TRANSPORT: Literal["polling", "ws"] = "polling"
|
||||
GLOBAL_ASYNC_QUERIES_POLLING_DELAY = int(
|
||||
timedelta(milliseconds=500).total_seconds() * 1000
|
||||
@@ -2446,6 +2474,8 @@ WELCOME_PAGE_LAST_TAB: Literal["examples", "all"] | tuple[str, list[dict[str, An
|
||||
ZIPPED_FILE_MAX_SIZE = 100 * 1024 * 1024 # 100MB
|
||||
# Max allowed compression ratio for a zipped file
|
||||
ZIP_FILE_MAX_COMPRESS_RATIO = 200.0
|
||||
# Max allowed total decompressed size across all entries in a zipped file
|
||||
ZIP_FILE_MAX_TOTAL_SIZE = 1024 * 1024 * 1024 # 1GB
|
||||
|
||||
# Configuration for environment tag shown on the navbar. Setting 'text' to '' will hide the tag. # noqa: E501
|
||||
# 'color' support only Ant Design semantic colors (e.g., 'error', 'warning', 'success', 'processing', 'default) # noqa: E501
|
||||
|
||||
@@ -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
|
||||
|
||||
44
superset/daos/extension.py
Normal file
44
superset/daos/extension.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from typing import Any
|
||||
|
||||
from superset import db
|
||||
from superset.daos.base import BaseDAO
|
||||
from superset.extensions.models import ExtensionSettings
|
||||
|
||||
# The global extension settings live in a single row; id is fixed so the row
|
||||
# can be fetched without a secondary lookup.
|
||||
SETTINGS_ROW_ID = 1
|
||||
|
||||
|
||||
class ExtensionSettingsDAO(BaseDAO[ExtensionSettings]):
|
||||
"""Persistence for the singleton global extension settings row.
|
||||
|
||||
The row (id=1) holds global admin state such as the active chatbot id.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_settings_row() -> ExtensionSettings | None:
|
||||
return db.session.get(ExtensionSettings, SETTINGS_ROW_ID)
|
||||
|
||||
|
||||
def get_extension_settings() -> dict[str, Any]:
|
||||
"""Read-only view of the extension settings."""
|
||||
row = ExtensionSettingsDAO.get_settings_row()
|
||||
return {
|
||||
"active_chatbot_id": row.active_chatbot_id if row else None,
|
||||
}
|
||||
@@ -15,34 +15,43 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import mimetypes
|
||||
import re
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
from flask import send_file
|
||||
from flask.wrappers import Response
|
||||
from flask_appbuilder.api import BaseApi, expose, protect, safe
|
||||
from flask_appbuilder.api import expose, protect, safe
|
||||
|
||||
from superset.commands.extension.settings.get import GetExtensionSettingsCommand
|
||||
from superset.extensions.utils import (
|
||||
build_extension_data,
|
||||
get_extensions,
|
||||
)
|
||||
from superset.views.base_api import BaseSupersetApi
|
||||
|
||||
# Allowlist for publisher and name path parameters — alphanumeric, hyphens,
|
||||
# underscores only. Rejects path-traversal attempts (../), URL-encoded slashes,
|
||||
# and any other characters that could escape EXTENSIONS_PATH.
|
||||
_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
|
||||
class ExtensionsRestApi(BaseApi):
|
||||
def _validate_segment(value: str) -> bool:
|
||||
"""Return True if *value* is a safe publisher or name segment."""
|
||||
return bool(_SEGMENT_RE.match(value))
|
||||
|
||||
|
||||
class ExtensionsRestApi(BaseSupersetApi):
|
||||
allow_browser_login = True
|
||||
resource_name = "extensions"
|
||||
|
||||
def response(self, status_code: int, **kwargs: Any) -> Response:
|
||||
"""Helper method to create JSON responses."""
|
||||
from flask import jsonify
|
||||
|
||||
return jsonify(kwargs), status_code
|
||||
|
||||
def response_404(self) -> Response:
|
||||
"""Helper method to create 404 responses."""
|
||||
from flask import jsonify
|
||||
|
||||
return jsonify({"message": "Not found"}), 404
|
||||
class_permission_name = "Extensions"
|
||||
base_permissions = [
|
||||
"can_get_list",
|
||||
"can_get",
|
||||
"can_content",
|
||||
"can_info",
|
||||
"can_get_settings",
|
||||
]
|
||||
|
||||
@expose("/_info", methods=("GET",))
|
||||
@protect()
|
||||
@@ -72,13 +81,13 @@ class ExtensionsRestApi(BaseApi):
|
||||
@safe
|
||||
@expose("/", methods=("GET",))
|
||||
def get_list(self, **kwargs: Any) -> Response:
|
||||
"""List all enabled extensions.
|
||||
"""List all installed extensions.
|
||||
---
|
||||
get_list:
|
||||
summary: List all enabled extensions.
|
||||
summary: List all installed extensions.
|
||||
responses:
|
||||
200:
|
||||
description: List of all enabled extensions
|
||||
description: List of all installed extensions
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -158,7 +167,8 @@ class ExtensionsRestApi(BaseApi):
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
# Reconstruct composite ID from publisher and name
|
||||
if not _validate_segment(publisher) or not _validate_segment(name):
|
||||
return self.response(400, message="Invalid publisher or name.")
|
||||
composite_id = f"{publisher}.{name}"
|
||||
extensions = get_extensions()
|
||||
extension = extensions.get(composite_id)
|
||||
@@ -167,6 +177,23 @@ class ExtensionsRestApi(BaseApi):
|
||||
extension_data = build_extension_data(extension)
|
||||
return self.response(200, result=extension_data)
|
||||
|
||||
@protect()
|
||||
@safe
|
||||
@expose("/settings", methods=("GET",))
|
||||
def get_settings(self, **kwargs: Any) -> Response:
|
||||
"""Get global extension admin settings.
|
||||
|
||||
No admin gate here by design: authenticated non-admin users need these
|
||||
settings so the ChatbotMount can read active_chatbot_id on every page.
|
||||
---
|
||||
get:
|
||||
summary: Get extension admin settings (active chatbot id).
|
||||
responses:
|
||||
200:
|
||||
description: Extension settings
|
||||
"""
|
||||
return self.response(200, result=GetExtensionSettingsCommand().run())
|
||||
|
||||
@protect()
|
||||
@safe
|
||||
@expose("/<publisher>/<name>/<file>", methods=("GET",))
|
||||
@@ -210,7 +237,8 @@ class ExtensionsRestApi(BaseApi):
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
# Reconstruct composite ID from publisher and name
|
||||
if not _validate_segment(publisher) or not _validate_segment(name):
|
||||
return self.response(400, message="Invalid publisher or name.")
|
||||
composite_id = f"{publisher}.{name}"
|
||||
extensions = get_extensions()
|
||||
extension = extensions.get(composite_id)
|
||||
|
||||
@@ -14,21 +14,19 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from flask_appbuilder import expose
|
||||
from flask_appbuilder.security.decorators import has_access, permission_name
|
||||
|
||||
from superset.constants import MODEL_VIEW_RW_METHOD_PERMISSION_MAP
|
||||
from superset.superset_typing import FlaskResponse
|
||||
from superset.views.base import BaseSupersetView
|
||||
"""SQLAlchemy models for extension settings persistence."""
|
||||
|
||||
from flask_appbuilder import Model
|
||||
from sqlalchemy import Column, Integer, String
|
||||
|
||||
# Column length for extension/chatbot identifiers.
|
||||
EXTENSION_ID_MAX_LENGTH = 250
|
||||
|
||||
|
||||
class ExtensionsView(BaseSupersetView):
|
||||
route_base = "/extensions"
|
||||
class_permission_name = "Extensions"
|
||||
method_permission_name = MODEL_VIEW_RW_METHOD_PERMISSION_MAP
|
||||
class ExtensionSettings(Model): # pylint: disable=too-few-public-methods
|
||||
"""Global admin settings for extensions (singleton row, id=1)."""
|
||||
|
||||
@expose("/list/")
|
||||
@has_access
|
||||
@permission_name("read")
|
||||
def list(self) -> FlaskResponse:
|
||||
return super().render_app_template()
|
||||
__tablename__ = "extension_settings"
|
||||
id = Column(Integer, primary_key=True)
|
||||
active_chatbot_id = Column(String(EXTENSION_ID_MAX_LENGTH), nullable=True)
|
||||
@@ -36,7 +36,11 @@ from superset.utils.core import check_is_safe_zip
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FRONTEND_REGEX = re.compile(r"^frontend/dist/([^/]+)$")
|
||||
BACKEND_REGEX = re.compile(r"^backend/src/(.+)$")
|
||||
# Reject any entry whose path contains "..", conservatively excluding parent
|
||||
# traversal segments along with the (in practice nonexistent) case of a module
|
||||
# path embedding consecutive dots, so a crafted entry name cannot produce a
|
||||
# traversal-style module path (defense in depth; check_is_safe_zip runs first).
|
||||
BACKEND_REGEX = re.compile(r"^backend/src/(?!.*\.\.)(.+)$")
|
||||
|
||||
|
||||
class InMemoryLoader(importlib.abc.Loader):
|
||||
@@ -234,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:
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from hashlib import md5
|
||||
from secrets import token_urlsafe
|
||||
from typing import Any
|
||||
@@ -30,16 +31,31 @@ from superset.key_value.exceptions import KeyValueParseKeyError
|
||||
from superset.key_value.types import Key, KeyValueFilter, KeyValueResource
|
||||
from superset.utils.json import json_dumps_w_dates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HASHIDS_MIN_LENGTH = 11
|
||||
|
||||
# Minimum number of bytes of entropy for generated keys (128-bit).
|
||||
MIN_KEY_NBYTES = 16
|
||||
|
||||
def random_key(nbytes: int = 8) -> str:
|
||||
|
||||
def random_key(nbytes: int = MIN_KEY_NBYTES) -> str:
|
||||
"""
|
||||
Generate a random URL-safe string.
|
||||
|
||||
Args:
|
||||
nbytes (int): Number of bytes to use for generating the key. Default is 8.
|
||||
nbytes (int): Number of bytes of entropy to use for generating the key.
|
||||
Defaults to 16 (128-bit). Values below 16 are rejected so that
|
||||
security-sensitive keys cannot request weaker entropy.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``nbytes`` is smaller than the 128-bit minimum.
|
||||
"""
|
||||
if nbytes < MIN_KEY_NBYTES:
|
||||
raise ValueError(
|
||||
f"random_key requires at least {MIN_KEY_NBYTES} bytes of entropy "
|
||||
f"(got {nbytes})"
|
||||
)
|
||||
return token_urlsafe(nbytes)
|
||||
|
||||
|
||||
@@ -69,7 +85,16 @@ def decode_permalink_id(key: str, salt: str) -> int:
|
||||
|
||||
|
||||
def _uuid_namespace_from_md5(seed: str) -> UUID:
|
||||
"""Generate UUID namespace from MD5 hash (legacy compatibility)."""
|
||||
"""Generate UUID namespace from MD5 hash (legacy compatibility).
|
||||
|
||||
The MD5 path is retained only for backwards compatibility with namespaces
|
||||
generated before SHA-256 became the default. It is deprecated and should not
|
||||
be selected for new deployments; prefer the SHA-256 generator instead.
|
||||
"""
|
||||
logger.warning(
|
||||
"The 'md5' HASH_ALGORITHM is deprecated and retained only for "
|
||||
"backwards compatibility; prefer 'sha256' for namespace generation."
|
||||
)
|
||||
md5_obj = md5() # noqa: S324
|
||||
md5_obj.update(seed.encode("utf-8"))
|
||||
return UUID(md5_obj.hexdigest())
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Add extension_settings table for chatbot admin selection and enable/disable.
|
||||
|
||||
Revision ID: b2c3d4e5f6a7
|
||||
Revises: 33d7e0e21daa
|
||||
Create Date: 2026-05-25 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "b2c3d4e5f6a7"
|
||||
down_revision = "33d7e0e21daa"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"extension_settings",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("active_chatbot_id", sa.String(250), nullable=True),
|
||||
)
|
||||
op.create_table(
|
||||
"extension_enabled",
|
||||
sa.Column("extension_id", sa.String(250), primary_key=True),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("extension_enabled")
|
||||
op.drop_table("extension_settings")
|
||||
@@ -0,0 +1,43 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Drop extension_enabled table (ExtensionEnabled model removed in chatbot SIP).
|
||||
|
||||
Revision ID: d1e2f3a4b5c6
|
||||
Revises: b2c3d4e5f6a7
|
||||
Create Date: 2026-06-09 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from superset.migrations.shared.utils import create_table, drop_table
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "d1e2f3a4b5c6"
|
||||
down_revision = "b2c3d4e5f6a7"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
drop_table("extension_enabled")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
create_table(
|
||||
"extension_enabled",
|
||||
sa.Column("extension_id", sa.String(250), primary_key=True),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
)
|
||||
@@ -14,4 +14,6 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from superset.extensions import models as extensions_models # noqa: F401
|
||||
|
||||
from . import core, dynamic_plugins, sql_lab, user_attributes # noqa: F401
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user