Compare commits

..

37 Commits

Author SHA1 Message Date
Enzo Martellucci
09c09f3f6b refactor: rename chatbot registration location 2026-06-10 14:32:52 +02:00
Enzo Martellucci
c65c9523aa refactor(extensions): remove install/lifecycle/dependency machinery (#40916) 2026-06-09 22:20:40 +02:00
Enzo Martellucci
94e0071883 Merge branch 'master' into enxdev/chat-prototype
Bring the chatbot extension feature branch up to date with master. The
chatbot work lives in new paths (superset/extensions/*, the core chatbot
namespace, ChatbotMount, superset-core namespaces) and merged cleanly with
no conflicts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:55:53 +02:00
Daniel Vaz Gaspar
2f71771b56 fix(sqllab): prevent corrupted query state from blocking SQL Lab access (#40580)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-06-09 10:51:45 +01:00
Mehmet Salih Yavuz
d7ddf2023d fix(theme): SDK theme config overrides dashboard-level theme in embedded mode (#40763) 2026-06-09 12:01:57 +03:00
Evan Rusackas
c58408d76c fix(revert 40875): "ci: authenticate Docker Hub pulls for service containers" failed (#40879) 2026-06-09 11:17:59 +07:00
Evan Rusackas
1188cfef1d ci: make Docker-build npm ci resilient to transient network blips (#40874)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-09 08:58:01 +07:00
Evan Rusackas
fb0e7fecaf ci: authenticate Docker Hub pulls for service containers (#40875)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-09 08:57:31 +07:00
Evan Rusackas
3afbb48188 fix(uploads,dao): add zip-safety check to columnar reader and cap DAO page size (#40637)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-08 17:07:57 -07:00
Evan Rusackas
837f41986d fix: reject default guest/async JWT secrets at startup (#40649)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-08 16:53:37 -07:00
Evan Rusackas
8eda626466 fix: raise random_key entropy and add expiry to async query tokens (#40638)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-08 16:24:06 -07:00
Evan Rusackas
fe9818226d fix(viz): gate stacktrace behind SHOW_STACKTRACE and allowlist resample method (#40636)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-08 16:09:59 -07:00
Joe Li
1e8438a478 test(dashboard): migrate favorite toggle Cypress spec to RTL (#40872)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 16:03:59 -07:00
dependabot[bot]
8fdabc44f5 chore(deps): update react-draggable requirement from ^4.5.0 to ^4.6.0 in /superset-frontend/packages/superset-ui-core (#40841)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 15:56:20 -07:00
Evan Rusackas
e9e9245112 test(mixed-chart): dashboard filters should reach both Mixed chart queries (#29519) (#40818)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-08 15:55:41 -07:00
Evan Rusackas
580be2cf32 fix(extensions-cli): constrain backend include patterns to the backend directory (#40593)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-08 15:42:06 -07:00
Evan Rusackas
911bb9dcda fix: harden ZIP safety checks (total-size cap, zero-division guard) and extension path matching (#40664)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-08 14:14:53 -07:00
Enzo Martellucci
380e70060b feat(extensions): define the superset.chatbot contribution point (#40439) 2026-06-08 22:50:34 +02:00
Evan Rusackas
507cf93687 test(dashboard): API-created dashboards should link charts from position_json (#32966) (#40816)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-08 10:51:25 -07:00
dependabot[bot]
ba6e9cc90f chore(deps-dev): bump eslint from 10.4.0 to 10.4.1 in /superset-websocket (#40840)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 10:29:08 -07:00
dependabot[bot]
228ac0d568 chore(deps): bump baseline-browser-mapping from 2.10.32 to 2.10.33 in /docs (#40842)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 10:27:31 -07:00
dependabot[bot]
c6ecaf9642 chore(deps): bump js-yaml from 4.1.1 to 4.2.0 in /docs (#40843)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 10:27:16 -07:00
dependabot[bot]
534d2191ff chore(deps): bump react-draggable from 4.5.0 to 4.6.0 in /superset-frontend (#40844)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:27:03 -07:00
dependabot[bot]
709fd52b0b chore(deps-dev): bump tsx from 4.22.3 to 4.22.4 in /superset-frontend (#40845)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 10:26:29 -07:00
dependabot[bot]
c5d795c1f1 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#40847)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 10:25:55 -07:00
dependabot[bot]
983f2818b0 chore(deps-dev): bump @swc/plugin-emotion from 14.10.0 to 14.12.0 in /superset-frontend (#40848)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 10:24:52 -07:00
dependabot[bot]
b4eda37fbf chore(deps-dev): bump baseline-browser-mapping from 2.10.32 to 2.10.33 in /superset-frontend (#40849)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 10:23:53 -07:00
Evan Rusackas
a5fe47ee71 docs(footer): render social icons as uniform white (#40854)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-08 09:30:58 -07:00
Onur Taşhan
dc423b22b3 feat(embedded): support themeMode URL param for initial theme (#40760)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:28:09 -07:00
Alexandru Soare
7c7ab88a60 feat(ListView): expose expandable prop (#40765) 2026-06-08 15:23:15 +03:00
jesperct
21189ae130 fix(dashboard): update browser tab title when dashboard is renamed (#40730)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
2026-06-08 10:42:59 +02:00
Evan Rusackas
06f95f5362 refactor(explore): migrate Explore Controls from react-dnd to @dnd-kit (#37880)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-07 20:00:03 -07:00
jesperct
5da63d716b fix(toasts): stop the toast overlay from covering controls behind it (#40805)
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-06-07 04:10:43 -07:00
dependabot[bot]
9bb700ff0d chore(deps-dev): bump concurrently from 9.2.1 to 10.0.0 in /superset-frontend (#40798)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-06-07 17:02:09 +07:00
dependabot[bot]
c0a12f4cfb chore(deps-dev): update sqlalchemy-kusto requirement from <4,>=3.0.0 to >=3.1.2,<4 (#40828)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-07 17:01:25 +07:00
dependabot[bot]
138e405cb6 chore(deps): bump xlsxwriter from 3.0.9 to 3.2.9 (#40825)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:36:03 +07:00
dependabot[bot]
849f297e9d chore(deps-dev): update sqlalchemy-vertica-python requirement from <0.7,>=0.5.9 to >=0.6.3,<0.7 (#40826)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-07 13:16:27 +07:00
148 changed files with 9539 additions and 3513 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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"

View File

@@ -109,7 +109,7 @@ dependencies = [
"watchdog>=6.0.0",
"wtforms>=2.3.3, <4",
"wtforms-json",
"xlsxwriter>=3.0.7, <3.3",
"xlsxwriter>=3.2.9, <3.3",
]
[project.optional-dependencies]
@@ -165,7 +165,7 @@ hive = [
"thrift_sasl>=0.4.3, < 1.0.0",
]
impala = ["impyla>0.16.2, <0.23"]
kusto = ["sqlalchemy-kusto>=3.0.0, <4"]
kusto = ["sqlalchemy-kusto>=3.1.2, <4"]
kylin = ["kylinpy>=2.8.1, <2.9"]
mssql = ["pymssql>=2.2.8, <3"]
# motherduck is an alias for duckdb - MotherDuck works via the duckdb driver
@@ -203,7 +203,7 @@ tdengine = [
]
teradata = ["teradatasql>=16.20.0.23"]
thumbnails = [] # deprecated, will be removed in 7.0
vertica = ["sqlalchemy-vertica-python>= 0.5.9, < 0.7"]
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
netezza = ["nzalchemy>=11.0.2"]
starrocks = ["starrocks>=1.0.0"]
doris = ["pydoris>=1.0.0, <2.0.0"]

View File

@@ -490,7 +490,7 @@ wtforms-json==0.3.5
# via apache-superset (pyproject.toml)
xlrd==2.0.1
# via pandas
xlsxwriter==3.0.9
xlsxwriter==3.2.9
# via
# apache-superset (pyproject.toml)
# pandas

View File

@@ -1140,7 +1140,7 @@ xlrd==2.0.1
# via
# -c requirements/base-constraint.txt
# pandas
xlsxwriter==3.0.9
xlsxwriter==3.2.9
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -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",

View File

@@ -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
},
},
});
```

View File

@@ -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

View File

@@ -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

View File

@@ -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)');
});
});

View File

@@ -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');
}

View File

@@ -14,12 +14,13 @@
],
"command": {
"publish": {
"message": "chore(superset-ui): publish %s"
"message": "chore(superset-ui): publish %s",
"graphType": "all"
},
"version": {
"message": "chore(superset-ui): publish %s",
"exact": true
}
},
"$schema": "node_modules/@lerna-lite/cli/schemas/lerna-schema.json"
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}

File diff suppressed because it is too large Load Diff

View File

@@ -82,7 +82,7 @@
"prune": "rm -rf ./{packages,plugins}/*/{node_modules,lib,esm,tsconfig.tsbuildinfo,package-lock.json} ./.temp_cache",
"storybook": "cross-env NODE_ENV=development BABEL_ENV=development storybook dev -p 6006",
"test-storybook": "test-storybook",
"test-storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"npx wait-on tcp:127.0.0.1:6006 && npm run test-storybook -- --maxWorkers=2\"",
"test-storybook:ci": "concurrently --kill-others --success first --names \"SB,TEST\" --prefix-colors \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"npx wait-on tcp:127.0.0.1:6006 && npm run test-storybook -- --maxWorkers=2\"",
"tdd": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --watch",
"test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80% --silent",
"test-loud": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80%",
@@ -263,10 +263,6 @@
"@emotion/jest": "^11.14.2",
"@formatjs/intl-durationformat": "^0.10.3",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@lerna-lite/cli": "^4.11.0",
"@lerna-lite/exec": "^4.11.0",
"@lerna-lite/publish": "^4.11.0",
"@lerna-lite/version": "^4.11.0",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.60.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
@@ -283,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",
@@ -317,9 +313,9 @@
"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": "^9.2.1",
"concurrently": "^10.0.0",
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
@@ -353,6 +349,7 @@
"jest-websocket-mock": "^2.5.0",
"js-yaml-loader": "^1.2.2",
"jsdom": "^29.1.1",
"lerna": "^9.0.4",
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.2",
"open-cli": "^9.0.0",
@@ -373,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",

View File

@@ -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"

View File

@@ -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 */

View File

@@ -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[]>>;
}
/**

View 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>;

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -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';

View File

@@ -0,0 +1,84 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @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>;

View File

@@ -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;

View File

@@ -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 (`core.chatbot` — singleton, host renders one)
* ```typescript
* views.registerView(
* { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' },
* 'core.chatbot',
* () => <ChatbotApp />,
* );
* ```
*/
export declare function registerView(
view: View,

View File

@@ -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",

View File

@@ -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}
>

View File

@@ -47,7 +47,7 @@ export interface ModalProps {
resizable?: boolean;
resizableConfig?: ResizableProps;
draggable?: boolean;
draggableConfig?: DraggableProps;
draggableConfig?: Partial<DraggableProps>;
destroyOnHidden?: boolean;
maskClosable?: boolean;
zIndex?: number;

View File

@@ -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>
),

View File

@@ -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,
},
};

View File

@@ -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>) => {

View File

@@ -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());
}
};

View File

@@ -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);
},
);

View File

@@ -113,7 +113,8 @@ function getPackages(packagePattern, tsOnly = false) {
let scope = getPackages(glob);
console.log('--- Run babel --------');
const babelCommand = `lerna exec --stream --concurrency 10 --scope ${scope} -- babel ${BABEL_CONFIG} src --extensions ".ts,.tsx,.js,.jsx" --copy-files`;
const babelCommand = `lerna exec --stream --concurrency 10 --scope ${scope}
-- babel ${BABEL_CONFIG} src --extensions ".ts,.tsx,.js,.jsx" --copy-files`;
run(`${babelCommand} --out-dir lib`);
console.log('--- Run babel esm ---');

View File

@@ -37,6 +37,7 @@ import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { DndContext } from '@dnd-kit/core';
import reducerIndex from 'spec/helpers/reducerIndex';
import { QueryParamProvider } from 'use-query-params';
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
@@ -47,6 +48,7 @@ import userEvent from '@testing-library/user-event';
type Options = Omit<RenderOptions, 'queries'> & {
useRedux?: boolean;
useDnd?: boolean;
useDndKit?: boolean; // Use @dnd-kit instead of react-dnd
useQueryParams?: boolean;
useRouter?: boolean;
useTheme?: boolean;
@@ -74,6 +76,7 @@ export const defaultStore = createStore();
export function createWrapper(options?: Options) {
const {
useDnd,
useDndKit,
useRedux,
useQueryParams,
useRouter,
@@ -96,6 +99,10 @@ export function createWrapper(options?: Options) {
);
}
if (useDndKit) {
result = <DndContext>{result}</DndContext>;
}
if (useDnd) {
// @ts-ignore react-dnd's DndProviderProps omits `children` under React 18 types
result = <DndProvider backend={HTML5Backend}>{result}</DndProvider>;

View File

@@ -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>
),
}));

View File

@@ -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: 'core.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: 'core.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: 'core.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
() => {
throw new Error('provider blew up');
},
),
);
// ChatbotRenderer wraps provider() in a component so ErrorBoundary catches
// synchronous throws from the provider function, not just from its output.
expect(() => render(<ChatbotMount />)).not.toThrow();
expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument();
});

View 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;

View File

@@ -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(

View File

@@ -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}</>;
}

View File

@@ -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) => {

View File

@@ -47,3 +47,13 @@ test('should pass removeToast to the Toast component', async () => {
fireEvent.click(getAllByTestId('close-button')[0]);
await waitFor(() => expect(removeToast).toHaveBeenCalledTimes(1));
});
test('presenter caps its height with max-height so it hugs the toasts', () => {
// A fixed `height` would make the fixed overlay span the viewport and block
// controls underneath it; `max-height` lets it shrink to the toasts while
// still scrolling when they overflow.
const presenter = setup().container.querySelector('#toast-presenter');
expect(presenter).toBeInTheDocument();
expect(presenter).toHaveStyleRule('max-height', 'calc(100vh - 100px)');
expect(presenter).not.toHaveStyleRule('height', 'calc(100vh - 100px)');
});

View File

@@ -37,7 +37,9 @@ const StyledToastPresenter = styled.div<VisualProps>(
z-index: ${theme.zIndexPopupBase + 1};
word-break: break-word;
height: calc(100vh - 100px);
/* Cap height for scrolling, but hug the toasts so the fixed overlay does not
reserve the full viewport and block controls underneath it. */
max-height: calc(100vh - 100px);
display: flex;
flex-direction: ${position === 'bottom' ? 'column-reverse' : 'column'};

View 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: 'core.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
provider,
),
);
const active = getActiveChatbot();
expect(active).toEqual({ id: 'core.chatbot', provider });
});
test('getActiveChatbot picks the first-to-register when multiple are installed', () => {
const firstProvider = () => React.createElement('div', null, 'First');
const secondProvider = () => React.createElement('div', null, 'Second');
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: 'core.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
provider,
);
expect(getActiveChatbot()?.id).toBe('core.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');
});

View 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 `core.chatbot`
* contribution area.
*
* `core.chatbot` is a singleton contribution area: multiple chatbot
* extensions may register a view there, but the host renders exactly one.
* This module owns the host-side selection policy.
*
* This is host-internal infrastructure — it is NOT part of the public
* `@apache-superset/core` API. Extensions register via the public
* `views.registerView()`; only the host resolves which one is active.
*/
import { ReactElement } from 'react';
import { CHATBOT_LOCATION } from 'src/views/contributions';
import { getRegisteredViewIds, getViewProvider } from 'src/core/views';
/**
* The resolved active chatbot: a view id paired with its renderable provider.
*/
export interface ActiveChatbot {
/** The registered view id of the selected chatbot. */
id: string;
/** The provider that renders the chatbot's React element. */
provider: () => ReactElement;
}
/**
* Resolves which single chatbot extension is currently active.
*
* Selection policy:
* - If no chatbot is registered, returns `undefined` — the corner stays empty.
* - 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;
};

View 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();
});

View 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,
};

View 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,
};

View 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();
});

View 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,
};

View File

@@ -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;
};

View File

@@ -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';

View 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');
});

View 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,
};

View File

@@ -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,
);

View File

@@ -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');

View File

@@ -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>,

View File

@@ -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' },
'core.chatbot',
provider,
),
);
expect(getViewProvider('core.chatbot', 'test.provider')).toBe(provider);
});
test('getViewProvider returns undefined when the location does not match', () => {
const provider = () => React.createElement('div', null, 'Test');
disposables.push(
views.registerView(
{ id: 'test.provider', name: 'Test Provider' },
'sqllab.panels',
provider,
),
);
// Registered, but at a different location.
expect(getViewProvider('core.chatbot', 'test.provider')).toBeUndefined();
});
test('getViewProvider returns undefined for an unknown id', () => {
expect(getViewProvider('core.chatbot', 'nonexistent')).toBeUndefined();
});
test('getRegisteredViewIds returns ids in registration order', () => {
const provider = () => React.createElement('div', null, 'Test');
disposables.push(
views.registerView(
{ id: 'first.chatbot', name: 'First' },
'core.chatbot',
provider,
),
views.registerView(
{ id: 'second.chatbot', name: 'Second' },
'core.chatbot',
provider,
),
);
expect(getRegisteredViewIds('core.chatbot')).toEqual([
'first.chatbot',
'second.chatbot',
]);
});
test('getRegisteredViewIds returns an empty array for an unused location', () => {
expect(getRegisteredViewIds('core.chatbot')).toEqual([]);
});

View File

@@ -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,

View File

@@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { fireEvent, render } from 'spec/helpers/testing-library';
import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
import { render } from 'spec/helpers/testing-library';
import DashboardWrapper from './DashboardWrapper';
@@ -39,50 +38,6 @@ test('should render children', () => {
expect(getByTestId('mock-children')).toBeInTheDocument();
});
test('should update the style on dragging state', async () => {
const defaultProps = {
label: <span>Test label</span>,
tooltipTitle: 'This is a tooltip title',
onRemove: jest.fn(),
onMoveLabel: jest.fn(),
onDropLabel: jest.fn(),
type: 'test',
index: 0,
};
const { container, getByText } = render(
<DashboardWrapper>
<OptionControlLabel
{...defaultProps}
index={1}
label={<span>Label 1</span>}
/>
<OptionControlLabel
{...defaultProps}
index={2}
label={<span>Label 2</span>}
/>
</DashboardWrapper>,
{
useRedux: true,
useDnd: true,
initialState: {
dashboardState: {
editMode: true,
},
},
},
);
expect(
container.getElementsByClassName('dragdroppable--dragging'),
).toHaveLength(0);
fireEvent.dragStart(getByText('Label 1'));
jest.runAllTimers();
expect(
container.getElementsByClassName('dragdroppable--dragging'),
).toHaveLength(1);
fireEvent.dragEnd(getByText('Label 1'));
// immediately discards dragging state after dragEnd
expect(
container.getElementsByClassName('dragdroppable--dragging'),
).toHaveLength(0);
});
// Note: Drag-and-drop test removed - DashboardWrapper uses react-dnd but
// OptionControlLabel uses @dnd-kit, causing cross-library compatibility issues.
// This test requires proper @dnd-kit testing utilities.

View File

@@ -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: {

View File

@@ -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);
});

View File

@@ -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(

View File

@@ -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;

View 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);
});

View 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;
}

View File

@@ -26,7 +26,7 @@ test('should render', async () => {
value={{ metric_name: 'test', uuid: '1' }}
type={DndItemType.Metric}
/>,
{ useDnd: true, useRedux: true, initialState: { explore: {} } },
{ useDndKit: true, useRedux: true, initialState: { explore: {} } },
);
expect(
@@ -34,17 +34,3 @@ test('should render', async () => {
).toBeInTheDocument();
expect(screen.getByText('test')).toBeInTheDocument();
});
test('should have attribute draggable:true', async () => {
render(
<DatasourcePanelDragOption
value={{ metric_name: 'test', uuid: '1' }}
type={DndItemType.Metric}
/>,
{ useDnd: true, useRedux: true, initialState: { explore: {} } },
);
expect(
await screen.findByTestId('DatasourcePanelDragOption'),
).toHaveAttribute('draggable', 'true');
});

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { RefObject, useMemo } from 'react';
import { useDrag } from 'react-dnd';
import { useDraggable } from '@dnd-kit/core';
import { useSelector } from 'react-redux';
import { Metric } from '@superset-ui/core';
import { css, styled, useTheme } from '@apache-superset/core/theme';
@@ -32,8 +32,8 @@ import { ExplorePageState } from 'src/explore/types';
import { DatasourcePanelDndItem } from '../types';
const DatasourceItemContainer = styled.div`
${({ theme }) => css`
const DatasourceItemContainer = styled.div<{ isDragging?: boolean }>`
${({ theme, isDragging }) => css`
display: flex;
align-items: center;
justify-content: space-between;
@@ -46,6 +46,8 @@ const DatasourceItemContainer = styled.div`
color: ${theme.colorText};
background-color: ${theme.colorBgLayout};
border-radius: 4px;
cursor: ${isDragging ? 'grabbing' : 'grab'};
opacity: ${isDragging ? 0.5 : 1};
&:hover {
background-color: ${theme.colorPrimaryBgHover};
@@ -98,15 +100,26 @@ export default function DatasourcePanelDragOption(
return true;
}, [type, value, compatibleMetrics, compatibleDimensions]);
const [{ isDragging }, drag] = useDrag({
item: {
value: props.value,
type: props.type,
// Create a unique ID for this draggable item
const draggableId = useMemo(() => {
if (type === DndItemType.Column) {
const col = value as ColumnMeta;
return `datasource-${type}-${col.column_name || col.verbose_name}`;
}
const metric = value as MetricOption;
return `datasource-${type}-${metric.metric_name || metric.label}`;
}, [type, value]);
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: draggableId,
data: {
type,
value,
},
canDrag: isCompatible,
collect: monitor => ({
isDragging: monitor.isDragging(),
}),
// @dnd-kit equivalent of react-dnd's `canDrag: isCompatible`. Disabling
// the draggable suppresses pointer activation entirely so incompatible
// items can't be picked up at all (matched in the visual style below).
disabled: !isCompatible,
});
const optionProps = {
@@ -118,10 +131,13 @@ export default function DatasourcePanelDragOption(
return (
<DatasourceItemContainer
data-test="DatasourcePanelDragOption"
ref={drag}
ref={setNodeRef}
isDragging={isDragging}
{...attributes}
{...listeners}
style={{
opacity: isCompatible ? 1 : 0.35,
cursor: isCompatible ? 'grab' : 'not-allowed',
opacity: isCompatible ? undefined : 0.35,
cursor: isCompatible ? undefined : 'not-allowed',
}}
>
{type === DndItemType.Column ? (

View File

@@ -17,13 +17,37 @@
* under the License.
*/
import { useContext } from 'react';
import { fireEvent, render } from 'spec/helpers/testing-library';
import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
import type { DragStartEvent } from '@dnd-kit/core';
import { act, fireEvent, render } from 'spec/helpers/testing-library';
import ExploreContainer, { DraggingContext, DropzoneContext } from '.';
import OptionWrapper from '../controls/DndColumnSelectControl/OptionWrapper';
import DatasourcePanelDragOption from '../DatasourcePanel/DatasourcePanelDragOption';
import { DndItemType } from '../DndItemType';
// @dnd-kit's PointerSensor only reacts to real pointer events, which jsdom
// cannot dispatch. To exercise the drag-start gating we capture the
// `onDragStart` handler the provider registers on DndContext and invoke it
// directly with a synthetic event.
let capturedOnDragStart: ((event: DragStartEvent) => void) | undefined;
jest.mock('@dnd-kit/core', () => {
const actual = jest.requireActual('@dnd-kit/core');
return {
...actual,
DndContext: ({
children,
onDragStart,
}: {
children: React.ReactNode;
onDragStart?: (event: DragStartEvent) => void;
}) => {
capturedOnDragStart = onDragStart;
return children;
},
};
});
beforeEach(() => {
capturedOnDragStart = undefined;
});
const MockChildren = () => {
const dragging = useContext(DraggingContext);
@@ -57,57 +81,62 @@ test('should render children', () => {
<ExploreContainer>
<MockChildren />
</ExploreContainer>,
{ useRedux: true, useDnd: true },
{ useRedux: true },
);
expect(getByTestId('mock-children')).toBeInTheDocument();
expect(getByText('not dragging')).toBeInTheDocument();
});
test('should only propagate dragging state when dragging the panel option', () => {
const defaultProps = {
label: <span>Test label</span>,
tooltipTitle: 'This is a tooltip title',
onRemove: jest.fn(),
onMoveLabel: jest.fn(),
onDropLabel: jest.fn(),
type: 'test',
index: 0,
};
test('should initially have dragging set to false', () => {
const { container, getByText } = render(
<ExploreContainer>
<DatasourcePanelDragOption
value={{ metric_name: 'panel option', uuid: '1' }}
type={DndItemType.Metric}
/>
<OptionControlLabel
{...defaultProps}
index={1}
label={<span>Metric item</span>}
/>
<OptionWrapper
{...defaultProps}
index={2}
label="Column item"
clickClose={() => {}}
onShiftOptions={() => {}}
/>
<MockChildren />
</ExploreContainer>,
{
useRedux: true,
useDnd: true,
},
{ useRedux: true },
);
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
fireEvent.dragStart(getByText('panel option'));
expect(getByText('not dragging')).toBeInTheDocument();
});
test('propagates dragging state when dragging a panel option', () => {
const { container } = render(
<ExploreContainer>
<MockChildren />
</ExploreContainer>,
{ useRedux: true },
);
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
// Dragging a DatasourcePanel option (no `dragIndex`) sets the dragging state.
act(() => {
capturedOnDragStart?.({
active: { id: 'panel', data: { current: { type: 'metric' } } },
} as unknown as DragStartEvent);
});
expect(container.getElementsByClassName('dragging')).toHaveLength(1);
fireEvent.dragEnd(getByText('panel option'));
fireEvent.dragStart(getByText('Metric item'));
});
test('does not propagate dragging state for an in-list reorder', () => {
const { container } = render(
<ExploreContainer>
<MockChildren />
</ExploreContainer>,
{ useRedux: true },
);
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
fireEvent.dragEnd(getByText('Metric item'));
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
// don't show dragging state for the sorting item
fireEvent.dragStart(getByText('Column item'));
// An in-list sortable reorder carries a `dragIndex` and must NOT set the
// dragging state (it would otherwise surface drop targets during a reorder).
act(() => {
capturedOnDragStart?.({
active: {
id: 'sortable',
data: { current: { type: 'metric', dragIndex: 0 } },
},
} as unknown as DragStartEvent);
});
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
});
@@ -116,10 +145,7 @@ test('should manage the dropValidators', () => {
<ExploreContainer>
<MockChildren2 />
</ExploreContainer>,
{
useRedux: true,
useDnd: true,
},
{ useRedux: true },
);
expect(queryByText('test_item_1')).not.toBeInTheDocument();

View File

@@ -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 {
ActiveDragData,
DroppableData,
resolveDragEnd,
} from './ExploreDndContext';
const COLUMN = 'column';
const METRIC = 'metric';
const active = (data: ActiveDragData, id = 'drag-source') => ({
id,
data: { current: data },
});
const over = (
data: Partial<ActiveDragData> & DroppableData,
id = 'dropzone-target',
) => ({
id,
data: { current: data },
});
test('reorder fires the active item onShiftOptions callback', () => {
const onShiftOptions = jest.fn();
resolveDragEnd(
active({ type: COLUMN, dragIndex: 0, onShiftOptions }, 'sortable-column-0'),
over({ type: COLUMN, dragIndex: 2 }, 'sortable-column-2'),
);
expect(onShiftOptions).toHaveBeenCalledWith(0, 2);
});
test('reorder falls back to onMoveLabel when onShiftOptions is absent', () => {
const onMoveLabel = jest.fn();
const onDropLabel = jest.fn();
resolveDragEnd(
active(
{ type: METRIC, dragIndex: 1, onMoveLabel, onDropLabel },
'sortable-metric-1',
),
over({ type: METRIC, dragIndex: 0 }, 'sortable-metric-0'),
);
expect(onMoveLabel).toHaveBeenCalledWith(1, 0);
expect(onDropLabel).toHaveBeenCalled();
});
test('reorder does not fire across mismatched types', () => {
const onShiftOptions = jest.fn();
resolveDragEnd(
active({ type: COLUMN, dragIndex: 0, onShiftOptions }, 'sortable-column-0'),
over({ type: METRIC, dragIndex: 1 }, 'sortable-metric-1'),
);
expect(onShiftOptions).not.toHaveBeenCalled();
});
test('external drop fires onDrop and onDropValue when accepted', () => {
const onDrop = jest.fn();
const onDropValue = jest.fn();
const value = { column_name: 'a' };
resolveDragEnd(
active({ type: COLUMN, value }),
over({ accept: [COLUMN], canDrop: () => true, onDrop, onDropValue }),
);
expect(onDrop).toHaveBeenCalledWith({ type: COLUMN, value });
expect(onDropValue).toHaveBeenCalledWith(value);
});
test('external drop is blocked when the type is not accepted', () => {
const onDrop = jest.fn();
resolveDragEnd(
active({ type: METRIC, value: { metric_name: 'm' } }),
over({ accept: [COLUMN], canDrop: () => true, onDrop }),
);
expect(onDrop).not.toHaveBeenCalled();
});
test('external drop is blocked when canDrop rejects the item', () => {
const onDrop = jest.fn();
resolveDragEnd(
active({ type: COLUMN, value: { column_name: 'dupe' } }),
over({ accept: [COLUMN], canDrop: () => false, onDrop }),
);
expect(onDrop).not.toHaveBeenCalled();
});
test('drop with no canDrop validator defaults to accepting the item', () => {
const onDrop = jest.fn();
resolveDragEnd(
active({ type: COLUMN, value: { column_name: 'a' } }),
over({ accept: [COLUMN], onDrop }),
);
expect(onDrop).toHaveBeenCalled();
});
test('no-op when there is no droppable target', () => {
expect(() =>
resolveDragEnd(active({ type: COLUMN, value: {} }), null),
).not.toThrow();
});
test('no-op when dropping onto itself', () => {
const onDrop = jest.fn();
resolveDragEnd(
active({ type: COLUMN, value: {} }, 'same'),
over({ accept: [COLUMN], onDrop }, 'same'),
);
expect(onDrop).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,244 @@
/**
* 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 {
createContext,
useContext,
useState,
useCallback,
FC,
Dispatch,
useReducer,
} from 'react';
import {
DndContext,
useSensor,
useSensors,
PointerSensor,
KeyboardSensor,
DragStartEvent,
DragEndEvent,
UniqueIdentifier,
} from '@dnd-kit/core';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { DatasourcePanelDndItem } from '../DatasourcePanel/types';
/**
* Type for the active drag item data
*/
export interface ActiveDragData {
type: string;
value?: unknown;
dragIndex?: number;
// For sortable items - callback to handle reorder
onShiftOptions?: (dragIndex: number, hoverIndex: number) => void;
onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
onDropLabel?: () => void;
}
/**
* Context to track if something is being dragged (for visual feedback)
*/
export const DraggingContext = createContext(false);
/**
* Context to access the currently active drag item
*/
export const ActiveDragContext = createContext<ActiveDragData | null>(null);
/**
* Dropzone validation - used by controls to register what they can accept
*/
type CanDropValidator = (item: DatasourcePanelDndItem) => boolean;
type DropzoneSet = Record<string, CanDropValidator>;
type Action = { key: string; canDrop?: CanDropValidator };
export const DropzoneContext = createContext<[DropzoneSet, Dispatch<Action>]>([
{},
() => {},
]);
const dropzoneReducer = (state: DropzoneSet = {}, action: Action) => {
if (action.canDrop) {
return {
...state,
[action.key]: action.canDrop,
};
}
if (action.key) {
const newState = { ...state };
delete newState[action.key];
return newState;
}
return state;
};
/**
* Shape of the data a droppable (e.g. DndSelectLabel) exposes via its
* `useDroppable` data object so that drops can be dispatched on drag end.
*/
export interface DroppableData {
accept?: string[];
canDrop?: (item: DatasourcePanelDndItem) => boolean;
onDrop?: (item: DatasourcePanelDndItem) => void;
onDropValue?: (value: DatasourcePanelDndItem['value']) => void;
}
/**
* Pure dispatch logic for a @dnd-kit drag-end event, extracted so it can be
* unit-tested without simulating pointer events (which jsdom cannot drive).
*
* Mirrors the original react-dnd behavior:
* - Same-list sortable reorder fires the active item's reorder callback.
* - External drops (DatasourcePanel -> control) only fire `onDrop` when the
* dragged item's type is accepted AND the droppable's `canDrop` validator
* passes (react-dnd never fired `drop` when `canDrop` was false).
*/
export function resolveDragEnd(
active: { id: UniqueIdentifier; data: { current?: ActiveDragData } },
over: {
id: UniqueIdentifier;
data: { current?: Partial<ActiveDragData> & DroppableData };
} | null,
): void {
if (!over || active.id === over.id) {
return;
}
const activeData = active.data.current;
const overData = over.data.current;
// Same-list sortable reorder: both endpoints carry a dragIndex and type.
if (
activeData &&
overData &&
typeof activeData.dragIndex === 'number' &&
typeof overData.dragIndex === 'number' &&
activeData.type === overData.type
) {
const reorderCallback = activeData.onShiftOptions || activeData.onMoveLabel;
reorderCallback?.(activeData.dragIndex, overData.dragIndex);
activeData.onDropLabel?.();
return;
}
// External drop onto a droppable that exposes an onDrop handler.
if (activeData && overData?.onDrop) {
const { accept, canDrop, onDrop, onDropValue } = overData;
const item: DatasourcePanelDndItem = {
type: activeData.type as DatasourcePanelDndItem['type'],
value: activeData.value as DatasourcePanelDndItem['value'],
};
const typeAccepted = !accept || accept.includes(item.type);
if (typeAccepted && (canDrop?.(item) ?? true)) {
onDrop(item);
onDropValue?.(item.value);
}
}
}
interface ExploreDndContextProps {
children: React.ReactNode;
}
/**
* DnD context provider for the Explore view.
* Wraps @dnd-kit/core's DndContext and provides:
* - Dragging state tracking (for visual feedback)
* - Dropzone registration (for validation)
* - Drop dispatch via each droppable's `useDroppable` data object
*/
export const ExploreDndContextProvider: FC<ExploreDndContextProps> = ({
children,
}) => {
const [isDragging, setIsDragging] = useState(false);
const [activeData, setActiveData] = useState<ActiveDragData | null>(null);
const dropzoneValue = useReducer(dropzoneReducer, {});
// Configure sensors for drag detection. PointerSensor drives mouse/touch
// drags; KeyboardSensor adds keyboard-accessible reordering (an a11y win
// over the previous react-dnd HTML5 backend, which had none).
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // 5px movement required before drag starts
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragStart = useCallback((event: DragStartEvent) => {
const { active } = event;
const data = active.data.current as ActiveDragData | undefined;
// Don't set dragging state for reordering within a list
if (data && 'dragIndex' in data) {
return;
}
setIsDragging(true);
setActiveData(data || null);
}, []);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
setIsDragging(false);
setActiveData(null);
resolveDragEnd(
active as Parameters<typeof resolveDragEnd>[0],
over as Parameters<typeof resolveDragEnd>[1],
);
}, []);
const handleDragCancel = useCallback(() => {
setIsDragging(false);
setActiveData(null);
}, []);
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<DropzoneContext.Provider value={dropzoneValue}>
<DraggingContext.Provider value={isDragging}>
<ActiveDragContext.Provider value={activeData}>
{children}
</ActiveDragContext.Provider>
</DraggingContext.Provider>
</DropzoneContext.Provider>
</DndContext>
);
};
/**
* Hook to check if something is currently being dragged
*/
export const useIsDragging = () => useContext(DraggingContext);
/**
* Hook to get the active drag data
*/
export const useActiveDrag = () => useContext(ActiveDragContext);

View File

@@ -16,29 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
createContext,
useEffect,
useState,
Dispatch,
FC,
ReactNode,
useReducer,
} from 'react';
import { FC, ReactNode } from 'react';
import { styled } from '@apache-superset/core/theme';
import { useDragDropManager } from 'react-dnd';
import { DatasourcePanelDndItem } from '../DatasourcePanel/types';
import {
ExploreDndContextProvider,
DraggingContext,
DropzoneContext,
} from './ExploreDndContext';
type CanDropValidator = (item: DatasourcePanelDndItem) => boolean;
type DropzoneSet = Record<string, CanDropValidator>;
type Action = { key: string; canDrop?: CanDropValidator };
// Re-export contexts for backward compatibility
export { DraggingContext, DropzoneContext };
export const DraggingContext = createContext(false);
export const DropzoneContext = createContext<[DropzoneSet, Dispatch<Action>]>([
{},
() => {},
]);
const StyledDiv = styled.div`
display: flex;
flex-direction: column;
@@ -46,53 +34,10 @@ const StyledDiv = styled.div`
min-height: 0;
`;
const reducer = (state: DropzoneSet = {}, action: Action) => {
if (action.canDrop) {
return {
...state,
[action.key]: action.canDrop,
};
}
if (action.key) {
const newState = { ...state };
delete newState[action.key];
return newState;
}
return state;
};
const ExploreContainer: FC<{ children?: ReactNode }> = ({ children }) => {
const dragDropManager = useDragDropManager();
const [dragging, setDragging] = useState(
dragDropManager.getMonitor().isDragging(),
);
useEffect(() => {
const monitor = dragDropManager.getMonitor();
const unsub = monitor.subscribeToStateChange(() => {
const item = monitor.getItem() || {};
// don't show dragging state for the sorting item
if ('dragIndex' in item) {
return;
}
const isDragging = monitor.isDragging();
setDragging(isDragging);
});
return () => {
unsub();
};
}, [dragDropManager]);
const dropzoneValue = useReducer(reducer, {});
return (
<DropzoneContext.Provider value={dropzoneValue}>
<DraggingContext.Provider value={dragging}>
<StyledDiv>{children}</StyledDiv>
</DraggingContext.Provider>
</DropzoneContext.Provider>
);
};
const ExploreContainer: FC<{ children?: ReactNode }> = ({ children }) => (
<ExploreDndContextProvider>
<StyledDiv>{children}</StyledDiv>
</ExploreDndContextProvider>
);
export default ExploreContainer;

View File

@@ -127,6 +127,8 @@ const ContourControl = ({ onChange, ...props }: ContourControlProps) => {
accept={[]}
ghostButtonText={ghostButtonText}
onClickGhostButton={handleClickGhostButton}
sortableType="ContourOption"
itemCount={contours.length}
{...props}
/>
<ContourPopoverTrigger

View File

@@ -16,15 +16,41 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
fireEvent,
render,
screen,
within,
} from 'spec/helpers/testing-library';
import { useDroppable } from '@dnd-kit/core';
import { useSortable } from '@dnd-kit/sortable';
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
import { DndColumnMetricSelect } from 'src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect';
import DatasourcePanelDragOption from '../../DatasourcePanel/DatasourcePanelDragOption';
import { DndItemType } from '../../DndItemType';
import { DndItemType } from 'src/explore/components/DndItemType';
import {
CapturedDroppable,
CapturedSortables,
captureDroppableData,
captureSortableData,
simulateDrop,
simulateReorder,
} from './dndTestUtils';
const captured: CapturedDroppable = { current: undefined };
const sortables: CapturedSortables = { items: [] };
jest.mock('@dnd-kit/core', () => ({
...jest.requireActual('@dnd-kit/core'),
useDroppable: jest.fn(),
}));
jest.mock('@dnd-kit/sortable', () => ({
...jest.requireActual('@dnd-kit/sortable'),
useSortable: jest.fn(),
}));
beforeEach(() => {
captured.current = undefined;
sortables.items = [];
(useDroppable as jest.Mock).mockImplementation(
captureDroppableData(captured),
);
(useSortable as jest.Mock).mockImplementation(captureSortableData(sortables));
});
const defaultProps = {
name: 'test-control',
@@ -67,7 +93,7 @@ const defaultProps = {
test('renders with default props', () => {
render(<DndColumnMetricSelect {...defaultProps} />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(
@@ -77,7 +103,7 @@ test('renders with default props', () => {
test('renders with default props and multi = true', () => {
render(<DndColumnMetricSelect {...defaultProps} multi />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(
@@ -88,148 +114,122 @@ test('renders with default props and multi = true', () => {
test('render selected columns and metrics correctly', () => {
const values = ['column_a', 'metric_a'];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(screen.getByText('column_a')).toBeVisible();
expect(screen.getByText('metric_a')).toBeVisible();
});
// Drop behavior is exercised through `resolveDragEnd` (the production drag-end
// dispatcher) because @dnd-kit's PointerSensor needs real layout that jsdom
// cannot provide. See ./dndTestUtils and ExploreDndContext.test.tsx.
test('can drop columns and metrics', () => {
const values = ['column_a', 'metric_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_b', uuid: '1' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_b', uuid: '2' }}
type={DndItemType.Metric}
/>
<DndColumnMetricSelect {...defaultProps} value={values} multi />
</>,
{
useDnd: true,
useRedux: true,
},
const onChange = jest.fn();
render(
<DndColumnMetricSelect
{...defaultProps}
value={['column_a', 'metric_a']}
onChange={onChange}
multi
/>,
{ useDndKit: true, useRedux: true },
);
const columnOption = screen.getAllByTestId('DatasourcePanelDragOption')[0];
const metricOption = screen.getAllByTestId('DatasourcePanelDragOption')[1];
const currentSelection = getByTestId('dnd-labels-container');
simulateDrop(captured, {
type: DndItemType.Column,
value: { column_name: 'column_b' } as any,
});
expect(onChange).toHaveBeenLastCalledWith([
'column_a',
'metric_a',
'column_b',
]);
fireEvent.dragStart(columnOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
fireEvent.dragStart(metricOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection).toBeInTheDocument();
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_b' } as any,
});
expect(onChange).toHaveBeenLastCalledWith([
'column_a',
'metric_a',
'metric_b',
]);
});
test('cannot drop duplicate items', () => {
const values = ['column_a', 'metric_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_a', uuid: '1' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '2' }}
type={DndItemType.Metric}
/>
<DndColumnMetricSelect {...defaultProps} value={values} multi />
</>,
{
useDnd: true,
useRedux: true,
},
const onChange = jest.fn();
render(
<DndColumnMetricSelect
{...defaultProps}
value={['column_a', 'metric_a']}
onChange={onChange}
multi
/>,
{ useDndKit: true, useRedux: true },
);
const columnOption = screen.getAllByTestId('DatasourcePanelDragOption')[0];
const metricOption = screen.getAllByTestId('DatasourcePanelDragOption')[1];
const currentSelection = getByTestId('dnd-labels-container');
simulateDrop(captured, {
type: DndItemType.Column,
value: { column_name: 'column_a' } as any,
});
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_a' } as any,
});
const initialCount = currentSelection.children.length;
fireEvent.dragStart(columnOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
fireEvent.dragStart(metricOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection.children).toHaveLength(initialCount);
expect(onChange).not.toHaveBeenCalled();
});
test('can drop only selected metrics', () => {
const values = ['column_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_c', uuid: '2' }}
type={DndItemType.Metric}
/>
<DndColumnMetricSelect {...defaultProps} value={values} multi />
</>,
{
useDnd: true,
useRedux: true,
},
const onChange = jest.fn();
render(
<DndColumnMetricSelect
{...defaultProps}
value={['column_a']}
onChange={onChange}
multi
/>,
{ useDndKit: true, useRedux: true },
);
const selectedMetric = screen.getAllByTestId('DatasourcePanelDragOption')[0];
const unselectedMetric = screen.getAllByTestId(
'DatasourcePanelDragOption',
)[1];
const currentSelection = getByTestId('dnd-labels-container');
// metric_c is not in selectedMetrics -> rejected
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_c' } as any,
});
expect(onChange).not.toHaveBeenCalled();
const initialCount = currentSelection.children.length;
fireEvent.dragStart(unselectedMetric);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection.children).toHaveLength(initialCount);
fireEvent.dragStart(selectedMetric);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection).toBeInTheDocument();
// metric_a is in selectedMetrics -> accepted
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_a' } as any,
});
expect(onChange).toHaveBeenLastCalledWith(['column_a', 'metric_a']);
});
test('can drag and reorder items', async () => {
const values = ['column_a', 'metric_a', 'column_b'];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDnd: true,
useRedux: true,
});
test('can drag and reorder items', () => {
const onChange = jest.fn();
render(
<DndColumnMetricSelect
{...defaultProps}
value={['column_a', 'metric_a', 'column_b']}
onChange={onChange}
multi
/>,
{ useDndKit: true, useRedux: true },
);
const container = screen.getByTestId('dnd-labels-container');
expect(container.childElementCount).toBe(4);
const firstItem = container.children[0] as HTMLElement;
const lastItem = container.children[2] as HTMLElement;
expect(within(firstItem).getByText('column_a')).toBeVisible();
expect(within(lastItem).getByText('Column B')).toBeVisible();
fireEvent.dragStart(firstItem);
fireEvent.dragEnter(lastItem);
fireEvent.dragOver(lastItem);
fireEvent.drop(lastItem);
expect(container).toBeInTheDocument();
// Reorder is dispatched via the active sortable item's onShiftOptions,
// which the control registers on each OptionWrapper. Drag index 0
// (column_a) onto index 2 (column_b) and verify the swap.
simulateReorder(sortables, 0, 2);
expect(onChange).toHaveBeenLastCalledWith([
'column_b',
'metric_a',
'column_a',
]);
});
test('shows warning for aggregated DeckGL charts', () => {
@@ -243,7 +243,7 @@ test('shows warning for aggregated DeckGL charts', () => {
multi
formData={formData}
/>,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
const columnItem = screen.getByText('column_a');
@@ -261,7 +261,7 @@ test('handles single selection mode', () => {
multi={false}
onChange={onChange}
/>,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
expect(screen.getByText('column_a')).toBeVisible();
@@ -275,7 +275,7 @@ test('handles custom ghost button text', () => {
render(
<DndColumnMetricSelect {...defaultProps} ghostButtonText={customText} />,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
expect(screen.getByText(customText)).toBeInTheDocument();
@@ -292,10 +292,11 @@ test('can remove items by clicking close button', () => {
multi
onChange={onChange}
/>,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
const closeButtons = screen.getAllByRole('button', { name: /close/i });
// Use testId instead of role selector - @dnd-kit sortable wrapper adds extra button elements
const closeButtons = screen.getAllByTestId('remove-control-button');
expect(closeButtons).toHaveLength(2);
fireEvent.click(closeButtons[0]);
@@ -312,7 +313,7 @@ test('handles adhoc metric with error', () => {
const values = [errorMetric];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
@@ -324,7 +325,7 @@ test('handles adhoc column values', () => {
const values = ['column_a'];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
@@ -336,7 +337,7 @@ test('handles mixed value types correctly', () => {
render(
<DndColumnMetricSelect {...defaultProps} value={mixedValues} multi />,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
expect(screen.getByText('column_a')).toBeVisible();

View File

@@ -61,7 +61,7 @@ const defaultProps: DndColumnSelectProps = {
test('renders with default props', async () => {
render(<DndColumnSelect {...defaultProps} />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(
@@ -71,7 +71,7 @@ test('renders with default props', async () => {
test('renders with value', async () => {
render(<DndColumnSelect {...defaultProps} value="Column A" />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(await screen.findByText('Column A')).toBeInTheDocument();
@@ -87,7 +87,7 @@ test('renders adhoc column', async () => {
expressionType: 'SQL',
}}
/>,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
expect(await screen.findByText('adhoc column')).toBeVisible();
expect(screen.getByLabelText('calculator')).toBeVisible();
@@ -110,7 +110,7 @@ test('warn selected custom metric when metric gets removed from dataset', async
value={columnValues}
/>,
{
useDnd: true,
useDndKit: true,
useRedux: true,
},
);
@@ -167,7 +167,7 @@ test('should allow selecting columns via click interface', async () => {
});
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});
@@ -200,7 +200,7 @@ test('should display selected column values correctly', async () => {
});
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});
@@ -233,7 +233,7 @@ test('should handle multiple column selections for groupby', async () => {
});
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});
@@ -269,7 +269,7 @@ test('should support adhoc column creation workflow', async () => {
});
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});
@@ -299,7 +299,7 @@ test('should verify onChange callback integration (core regression protection)',
};
const { rerender } = render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
@@ -334,7 +334,7 @@ test('should render column selection interface elements', async () => {
};
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
@@ -374,7 +374,7 @@ test('should complete full column selection workflow like original Cypress test'
});
const { rerender } = render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});
@@ -450,7 +450,7 @@ test('should create adhoc column via Custom SQL tab workflow', async () => {
});
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});

View File

@@ -204,6 +204,9 @@ function DndColumnSelect(props: DndColumnSelectProps) {
[ghostButtonText, multi],
);
// Generate sortable type that matches OptionWrapper's type
const sortableType = `${DndItemType.ColumnOption}_${name}_${label}`;
return (
<div>
<DndSelectLabel
@@ -214,6 +217,8 @@ function DndColumnSelect(props: DndColumnSelectProps) {
displayGhostButton={multi || optionSelector.values.length === 0}
ghostButtonText={labelGhostButtonText}
onClickGhostButton={openPopover}
sortableType={sortableType}
itemCount={optionSelector.values.length}
{...props}
/>
<ColumnSelectPopoverTrigger

View File

@@ -27,11 +27,14 @@ import {
import { GenericDataType } from '@apache-superset/core/common';
import { ColumnMeta } from '@superset-ui/chart-controls';
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from 'spec/helpers/testing-library';
import { useDroppable } from '@dnd-kit/core';
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
import { Operators } from 'src/explore/constants';
@@ -41,9 +44,13 @@ import {
} from 'src/explore/components/controls/DndColumnSelectControl/DndFilterSelect';
import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants';
import { ExpressionTypes } from '../FilterControl/types';
import { Datasource } from '../../../types';
import { DndItemType } from '../../DndItemType';
import DatasourcePanelDragOption from '../../DatasourcePanel/DatasourcePanelDragOption';
import { Datasource } from '../../../types';
import {
CapturedDroppable,
captureDroppableData,
simulateDrop,
} from './dndTestUtils';
jest.mock('src/core/editors', () => ({
EditorHost: ({ value }: { value: string }) => (
@@ -51,6 +58,21 @@ jest.mock('src/core/editors', () => ({
),
}));
jest.mock('@dnd-kit/core', () => ({
...jest.requireActual('@dnd-kit/core'),
useDroppable: jest.fn(),
}));
const captured: CapturedDroppable = { current: undefined };
beforeEach(() => {
jest.clearAllMocks();
captured.current = undefined;
(useDroppable as jest.Mock).mockImplementation(
captureDroppableData(captured),
);
});
const defaultProps: Omit<DndFilterSelectProps, 'datasource'> = {
type: 'DndFilterSelect',
name: 'Filter',
@@ -96,12 +118,8 @@ function setup({
);
}
beforeEach(() => {
jest.clearAllMocks();
});
test('renders with default props', async () => {
render(setup(), { useDnd: true, store });
render(setup(), { useDndKit: true, store });
expect(
await screen.findByText('Drop columns/metrics here or click'),
).toBeInTheDocument();
@@ -113,7 +131,7 @@ test('renders with value', async () => {
expressionType: ExpressionTypes.Sql,
});
render(setup({ value }), {
useDnd: true,
useDndKit: true,
store,
});
expect(await screen.findByText('COUNT(*)')).toBeInTheDocument();
@@ -128,7 +146,7 @@ test('renders options with saved metric', async () => {
},
}),
{
useDnd: true,
useDndKit: true,
store,
},
);
@@ -150,7 +168,7 @@ test('renders options with column', async () => {
],
}),
{
useDnd: true,
useDndKit: true,
store,
},
);
@@ -172,7 +190,7 @@ test('renders options with adhoc metric', async () => {
},
}),
{
useDnd: true,
useDndKit: true,
store,
},
);
@@ -181,60 +199,43 @@ test('renders options with adhoc metric', async () => {
).toBeInTheDocument();
});
test('cannot drop a column that is not part of the simple column selection', () => {
test('cannot drop a column that is not part of the simple column selection', async () => {
const adhocMetric = new AdhocMetric({
expression: 'AVG(birth_names.num)',
metric_name: 'avg__num',
});
const { getByTestId, getAllByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'order_date' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ column_name: 'address_line1' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{
metric_name: 'metric_a',
expression: 'AGG(metric_a)',
uuid: '1',
}}
type={DndItemType.Metric}
/>
{setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
columns: [{ column_name: 'order_date' }],
})}
</>,
render(
setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
columns: [{ column_name: 'order_date' }],
}),
{
useDnd: true,
useDndKit: true,
store,
},
);
const selections = getAllByTestId('DatasourcePanelDragOption');
const acceptableColumn = selections[0];
const unacceptableColumn = selections[1];
const metricType = selections[2];
const currentMetric = getByTestId('dnd-labels-container');
fireEvent.dragStart(unacceptableColumn);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
// A column missing from the simple column selection is rejected by canDrop,
// so no filter popover opens.
act(() => {
simulateDrop(captured, {
type: DndItemType.Column,
value: { column_name: 'address_line1' } as any,
});
});
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
fireEvent.dragStart(acceptableColumn);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
const filterConfigPopup = screen.getByTestId('filter-edit-popover');
// An acceptable column opens the popover prefilled with that column.
act(() => {
simulateDrop(captured, {
type: DndItemType.Column,
value: { column_name: 'order_date' } as any,
});
});
const filterConfigPopup = await screen.findByTestId('filter-edit-popover');
expect(within(filterConfigPopup).getByText('order_date')).toBeInTheDocument();
fireEvent.keyDown(filterConfigPopup, {
@@ -243,15 +244,111 @@ test('cannot drop a column that is not part of the simple column selection', ()
keyCode: 27,
charCode: 27,
});
await waitFor(() =>
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument(),
);
// A metric type is accepted (adhoc metrics are allowed here).
act(() => {
simulateDrop(captured, {
type: DndItemType.Metric,
value: {
metric_name: 'metric_a',
expression: 'AGG(metric_a)',
uuid: '1',
} as any,
});
});
const metricPopup = await screen.findByTestId('filter-edit-popover');
expect(within(metricPopup).getByTestId('react-ace')).toHaveTextContent(
'AGG(metric_a)',
);
});
test('when disallow_adhoc_metrics is set, can drop a column from the simple column selection', async () => {
const adhocMetric = new AdhocMetric({
expression: 'AVG(birth_names.num)',
metric_name: 'avg__num',
});
render(
setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
datasource: {
...PLACEHOLDER_DATASOURCE,
extra: '{ "disallow_adhoc_metrics": true }',
},
columns: [{ column_name: 'column_a' }, { column_name: 'column_b' }],
}),
{
useDndKit: true,
store,
},
);
act(() => {
simulateDrop(captured, {
type: DndItemType.Column,
value: { column_name: 'column_b' } as any,
});
});
const filterConfigPopup = await screen.findByTestId('filter-edit-popover');
expect(within(filterConfigPopup).getByText('column_b')).toBeInTheDocument();
});
test('when disallow_adhoc_metrics is set, cannot drop anything but a simple column selection', async () => {
const adhocMetric = new AdhocMetric({
expression: 'AVG(birth_names.num)',
metric_name: 'avg__num',
});
render(
setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
datasource: {
...PLACEHOLDER_DATASOURCE,
extra: '{ "disallow_adhoc_metrics": true }',
},
columns: [{ column_name: 'column_a' }, { column_name: 'column_c' }],
}),
{
useDndKit: true,
store,
},
);
// A metric is rejected when adhoc metrics are disallowed.
act(() => {
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_a', uuid: '1' } as any,
});
});
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
fireEvent.dragStart(metricType);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
// An adhoc metric option is likewise rejected.
act(() => {
simulateDrop(captured, {
type: DndItemType.AdhocMetricOption,
value: { metric_name: 'avg__num', uuid: '2' } as any,
});
});
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
expect(
within(screen.getByTestId('filter-edit-popover')).getByTestId('react-ace'),
).toHaveTextContent('AGG(metric_a)');
// A column from the simple selection is accepted.
act(() => {
simulateDrop(captured, {
type: DndItemType.Column,
value: { column_name: 'column_c' } as any,
});
});
const filterConfigPopup = await screen.findByTestId('filter-edit-popover');
expect(within(filterConfigPopup).getByText('column_c')).toBeInTheDocument();
});
test('calls onChange when close is clicked and canDelete is true', () => {
@@ -268,7 +365,7 @@ test('calls onChange when close is clicked and canDelete is true', () => {
const canDelete = jest.fn();
canDelete.mockReturnValue(true);
render(setup({ value: [value1, value2], additionalProps: { canDelete } }), {
useDnd: true,
useDndKit: true,
store,
});
fireEvent.click(screen.getAllByTestId('remove-control-button')[0]);
@@ -290,7 +387,7 @@ test('onChange is not called when close is clicked and canDelete is false', () =
const canDelete = jest.fn();
canDelete.mockReturnValue(false);
render(setup({ value: [value1, value2], additionalProps: { canDelete } }), {
useDnd: true,
useDndKit: true,
store,
});
fireEvent.click(screen.getAllByTestId('remove-control-button')[0]);
@@ -312,7 +409,7 @@ test('onChange is not called when close is clicked and canDelete is string, warn
const canDelete = jest.fn();
canDelete.mockReturnValue('Test warning');
render(setup({ value: [value1, value2], additionalProps: { canDelete } }), {
useDnd: true,
useDndKit: true,
store,
});
fireEvent.click(screen.getAllByTestId('remove-control-button')[0]);
@@ -320,109 +417,3 @@ test('onChange is not called when close is clicked and canDelete is string, warn
expect(defaultProps.onChange).not.toHaveBeenCalled();
expect(await screen.findByText('Test warning')).toBeInTheDocument();
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('when disallow_adhoc_metrics is set', () => {
test('can drop a column type from the simple column selection', () => {
const adhocMetric = new AdhocMetric({
expression: 'AVG(birth_names.num)',
metric_name: 'avg__num',
});
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_b' }}
type={DndItemType.Column}
/>
{setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
datasource: {
...PLACEHOLDER_DATASOURCE,
extra: '{ "disallow_adhoc_metrics": true }',
},
columns: [{ column_name: 'column_a' }, { column_name: 'column_b' }],
})}
</>,
{
useDnd: true,
store,
},
);
const acceptableColumn = getByTestId('DatasourcePanelDragOption');
const currentMetric = getByTestId('dnd-labels-container');
fireEvent.dragStart(acceptableColumn);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
const filterConfigPopup = screen.getByTestId('filter-edit-popover');
expect(within(filterConfigPopup).getByText('column_b')).toBeInTheDocument();
});
test('cannot drop any other types of selections apart from simple column selection', () => {
const adhocMetric = new AdhocMetric({
expression: 'AVG(birth_names.num)',
metric_name: 'avg__num',
});
const { getByTestId, getAllByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_c' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'avg__num', uuid: '2' }}
type={DndItemType.AdhocMetricOption}
/>
{setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
datasource: {
...PLACEHOLDER_DATASOURCE,
extra: '{ "disallow_adhoc_metrics": true }',
},
columns: [{ column_name: 'column_a' }, { column_name: 'column_c' }],
})}
</>,
{
useDnd: true,
store,
},
);
const selections = getAllByTestId('DatasourcePanelDragOption');
const acceptableColumn = selections[0];
const unacceptableMetric = selections[1];
const unacceptableType = selections[2];
const currentMetric = getByTestId('dnd-labels-container');
fireEvent.dragStart(unacceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
fireEvent.dragStart(unacceptableType);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
fireEvent.dragStart(acceptableColumn);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
const filterConfigPopup = screen.getByTestId('filter-edit-popover');
expect(within(filterConfigPopup).getByText('column_c')).toBeInTheDocument();
});
});

View File

@@ -454,6 +454,8 @@ const DndFilterSelect = (props: DndFilterSelectProps) => {
accept={DND_ACCEPTED_TYPES}
ghostButtonText={t('Drop columns/metrics here or click')}
onClickGhostButton={handleClickGhostButton}
sortableType={DndItemType.FilterOption}
itemCount={values.length}
{...props}
/>
<AdhocFilterPopoverTrigger

View File

@@ -20,15 +20,46 @@ import {
fireEvent,
render,
screen,
within,
userEvent,
waitFor,
within,
} from 'spec/helpers/testing-library';
import { useDroppable } from '@dnd-kit/core';
import { useSortable } from '@dnd-kit/sortable';
import { DndMetricSelect } from 'src/explore/components/controls/DndColumnSelectControl/DndMetricSelect';
import { AGGREGATES } from 'src/explore/constants';
import { EXPRESSION_TYPES } from '../MetricControl/AdhocMetric';
import DatasourcePanelDragOption from '../../DatasourcePanel/DatasourcePanelDragOption';
import { DndItemType } from '../../DndItemType';
import {
CapturedDroppable,
CapturedSortables,
captureDroppableData,
captureSortableData,
simulateDrop,
simulateReorder,
} from './dndTestUtils';
const captured: CapturedDroppable = { current: undefined };
const sortables: CapturedSortables = { items: [] };
jest.mock('@dnd-kit/core', () => ({
...jest.requireActual('@dnd-kit/core'),
useDroppable: jest.fn(),
}));
jest.mock('@dnd-kit/sortable', () => ({
...jest.requireActual('@dnd-kit/sortable'),
useSortable: jest.fn(),
}));
beforeEach(() => {
captured.current = undefined;
sortables.items = [];
(useDroppable as jest.Mock).mockImplementation(
captureDroppableData(captured),
);
(useSortable as jest.Mock).mockImplementation(captureSortableData(sortables));
});
const defaultProps = {
savedMetrics: [
@@ -70,7 +101,7 @@ const adhocMetricB = {
test('renders with default props', () => {
render(<DndMetricSelect {...defaultProps} />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(
@@ -80,7 +111,7 @@ test('renders with default props', () => {
test('renders with default props and multi = true', () => {
render(<DndMetricSelect {...defaultProps} multi />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(
@@ -91,7 +122,7 @@ test('renders with default props and multi = true', () => {
test('render selected metrics correctly', () => {
const metricValues = ['metric_a', 'metric_b', adhocMetricB];
render(<DndMetricSelect {...defaultProps} value={metricValues} multi />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(screen.getByText('metric_a')).toBeVisible();
@@ -113,7 +144,7 @@ test('warn selected custom metric when metric gets removed from dataset', async
multi
/>,
{
useDnd: true,
useDndKit: true,
useRedux: true,
},
);
@@ -166,7 +197,7 @@ test('warn selected custom metric when metric gets removed from dataset for sing
multi={false}
/>,
{
useDnd: true,
useDndKit: true,
useRedux: true,
},
);
@@ -225,7 +256,7 @@ test('remove selected adhoc metric when column gets removed from dataset', async
multi
/>,
{
useDnd: true,
useDndKit: true,
useRedux: true,
},
);
@@ -268,7 +299,7 @@ test('update adhoc metric name when column label in dataset changes', () => {
multi
/>,
{
useDnd: true,
useDndKit: true,
useRedux: true,
},
);
@@ -311,156 +342,107 @@ test('update adhoc metric name when column label in dataset changes', () => {
expect(screen.getByText('SUM(new col B name)')).toBeVisible();
});
test('can drag metrics', async () => {
const metricValues = ['metric_a', 'metric_b', adhocMetricB];
render(<DndMetricSelect {...defaultProps} value={metricValues} multi />, {
useDnd: true,
useRedux: true,
});
// Drop behavior is exercised through `resolveDragEnd` (the production drag-end
// dispatcher) because @dnd-kit's PointerSensor needs real layout that jsdom
// cannot provide. See ./dndTestUtils and ExploreDndContext.test.tsx.
expect(screen.getByText('metric_a')).toBeVisible();
expect(screen.getByText('Metric B')).toBeVisible();
test('can drag metrics (reorder dispatches through the reorder + drop path)', () => {
const onChange = jest.fn();
render(
<DndMetricSelect
{...defaultProps}
value={['metric_a', 'metric_b', adhocMetricB]}
onChange={onChange}
multi
/>,
{ useDndKit: true, useRedux: true },
);
const container = screen.getByTestId('dnd-labels-container');
expect(container.childElementCount).toBe(4);
// DndMetricSelect reorders via moveLabel (internal state) finalized by
// onDropLabel. Verify both callbacks were registered on the sortable items
// and the drag-end path invokes them (which commits the change via onChange).
expect(sortables.items.length).toBeGreaterThanOrEqual(3);
expect(typeof sortables.items[0].onMoveLabel).toBe('function');
expect(typeof sortables.items[0].onDropLabel).toBe('function');
const firstMetric = container.children[0] as HTMLElement;
const lastMetric = container.children[2] as HTMLElement;
expect(within(firstMetric).getByText('metric_a')).toBeVisible();
expect(within(lastMetric).getByText('SUM(Column B)')).toBeVisible();
fireEvent.mouseOver(within(firstMetric).getByText('metric_a'));
expect(await screen.findByText('Metric name')).toBeInTheDocument();
fireEvent.dragStart(firstMetric);
fireEvent.dragEnter(lastMetric);
fireEvent.dragOver(lastMetric);
fireEvent.drop(lastMetric);
expect(within(firstMetric).getByText('SUM(Column B)')).toBeVisible();
expect(within(lastMetric).getByText('metric_a')).toBeVisible();
simulateReorder(sortables, 0, 2);
expect(onChange).toHaveBeenCalled();
});
test('cannot drop a duplicated item', () => {
const metricValues = ['metric_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DndMetricSelect {...defaultProps} value={metricValues} multi />
</>,
{
useDnd: true,
useRedux: true,
},
const onChange = jest.fn();
render(
<DndMetricSelect
{...defaultProps}
value={['metric_a']}
onChange={onChange}
multi
/>,
{ useDndKit: true, useRedux: true },
);
const acceptableMetric = getByTestId('DatasourcePanelDragOption');
const currentMetric = getByTestId('dnd-labels-container');
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_a' } as any,
});
const currentMetricSelection = currentMetric.children.length;
fireEvent.dragStart(acceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection);
expect(currentMetric).toHaveTextContent('metric_a');
expect(onChange).not.toHaveBeenCalled();
});
test('can drop a saved metric when disallow_adhoc_metrics', () => {
const metricValues = ['metric_b'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DndMetricSelect
{...defaultProps}
value={metricValues}
multi
datasource={{ extra: '{ "disallow_adhoc_metrics": true }' }}
/>
</>,
{
useDnd: true,
useRedux: true,
},
const onChange = jest.fn();
render(
<DndMetricSelect
{...defaultProps}
value={['metric_b']}
onChange={onChange}
multi
datasource={{ extra: '{ "disallow_adhoc_metrics": true }' } as any}
/>,
{ useDndKit: true, useRedux: true },
);
const acceptableMetric = getByTestId('DatasourcePanelDragOption');
const currentMetric = getByTestId('dnd-labels-container');
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_a' } as any,
});
const currentMetricSelection = currentMetric.children.length;
fireEvent.dragStart(acceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection + 1);
expect(currentMetric.children[1]).toHaveTextContent('metric_a');
expect(onChange).toHaveBeenLastCalledWith(['metric_b', 'metric_a']);
});
test('cannot drop non-saved metrics when disallow_adhoc_metrics', () => {
const metricValues = ['metric_b'];
const { getByTestId, getAllByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_c', uuid: '2' }}
type={DndItemType.Metric}
/>
<DatasourcePanelDragOption
value={{ column_name: 'column_1', uuid: '3' }}
type={DndItemType.Column}
/>
<DndMetricSelect
{...defaultProps}
value={metricValues}
multi
datasource={{ extra: '{ "disallow_adhoc_metrics": true }' }}
/>
</>,
{
useDnd: true,
useRedux: true,
},
const onChange = jest.fn();
render(
<DndMetricSelect
{...defaultProps}
value={['metric_b']}
onChange={onChange}
multi
datasource={{ extra: '{ "disallow_adhoc_metrics": true }' } as any}
/>,
{ useDndKit: true, useRedux: true },
);
const selections = getAllByTestId('DatasourcePanelDragOption');
const acceptableMetric = selections[0];
const unacceptableMetric = selections[1];
const unacceptableType = selections[2];
const currentMetric = getByTestId('dnd-labels-container');
// Non-saved metric -> rejected.
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_c' } as any,
});
expect(onChange).not.toHaveBeenCalled();
const currentMetricSelection = currentMetric.children.length;
// Column type -> rejected when adhoc metrics are disallowed.
simulateDrop(captured, {
type: DndItemType.Column,
value: { column_name: 'column_a' } as any,
});
expect(onChange).not.toHaveBeenCalled();
fireEvent.dragStart(unacceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection);
expect(currentMetric).not.toHaveTextContent('metric_c');
fireEvent.dragStart(unacceptableType);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection);
expect(currentMetric).not.toHaveTextContent('column_1');
fireEvent.dragStart(acceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection + 1);
expect(currentMetric).toHaveTextContent('metric_a');
// Saved metric -> accepted.
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_a' } as any,
});
expect(onChange).toHaveBeenLastCalledWith(['metric_b', 'metric_a']);
});
test('title changes on custom SQL text change', async () => {
@@ -477,7 +459,7 @@ test('title changes on custom SQL text change', async () => {
multi
/>,
{
useDnd: true,
useDndKit: true,
useRedux: true,
},
);

View File

@@ -408,6 +408,9 @@ const DndMetricSelect = (props: any) => {
multi ? 2 : 1,
);
// Generate sortable type that matches MetricDefinitionValue's type
const sortableType = `${DndItemType.AdhocMetricOption}_${props.name}_${props.label}`;
return (
<div className="metrics-select">
<DndSelectLabel
@@ -418,6 +421,8 @@ const DndMetricSelect = (props: any) => {
ghostButtonText={ghostButtonText}
displayGhostButton={multi || value.length === 0}
onClickGhostButton={handleClickGhostButton}
sortableType={sortableType}
itemCount={value.length}
{...props}
/>
<AdhocMetricPopoverTrigger

View File

@@ -52,7 +52,7 @@ const MockChildren = () => {
};
test('renders with default props', () => {
render(<DndSelectLabel {...defaultProps} />, { useDnd: true });
render(<DndSelectLabel {...defaultProps} />, { useDndKit: true });
expect(screen.getByText('Drop columns here or click')).toBeInTheDocument();
});
@@ -60,7 +60,7 @@ test('renders ghost button when empty', () => {
const ghostButtonText = 'Ghost button text';
render(
<DndSelectLabel {...defaultProps} ghostButtonText={ghostButtonText} />,
{ useDnd: true },
{ useDndKit: true },
);
expect(screen.getByText(ghostButtonText)).toBeInTheDocument();
});
@@ -69,13 +69,13 @@ test('renders values', () => {
const values = 'Values';
const valuesRenderer = () => <span>{values}</span>;
render(<DndSelectLabel {...defaultProps} valuesRenderer={valuesRenderer} />, {
useDnd: true,
useDndKit: true,
});
expect(screen.getByText(values)).toBeInTheDocument();
});
test('Handles ghost button click', () => {
render(<DndSelectLabel {...defaultProps} />, { useDnd: true });
render(<DndSelectLabel {...defaultProps} />, { useDndKit: true });
userEvent.click(screen.getByText('Drop columns here or click'));
expect(defaultProps.onClickGhostButton).toHaveBeenCalled();
});
@@ -86,7 +86,6 @@ test('updates dropValidator on changes', () => {
<DndSelectLabel {...defaultProps} />
<MockChildren />
</ExploreContainer>,
{ useDnd: true },
);
expect(getByTestId(`mock-result-${defaultProps.name}`)).toHaveTextContent(
'false',

View File

@@ -17,7 +17,11 @@
* under the License.
*/
import { ReactNode, useCallback, useContext, useEffect, useMemo } from 'react';
import { useDrop } from 'react-dnd';
import { useDroppable } from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { t } from '@apache-superset/core/translation';
import ControlHeader from 'src/explore/components/ControlHeader';
import {
@@ -45,6 +49,9 @@ export type DndSelectLabelProps = {
displayGhostButton?: boolean;
onClickGhostButton: () => void;
isLoading?: boolean;
// For sortable items - the type string and count to generate sortable IDs
sortableType?: string;
itemCount?: number;
};
export default function DndSelectLabel({
@@ -52,35 +59,49 @@ export default function DndSelectLabel({
accept,
valuesRenderer,
isLoading,
sortableType,
itemCount = 0,
...props
}: DndSelectLabelProps) {
const canDropProp = props.canDrop;
const canDropValueProp = props.canDropValue;
const acceptTypes = useMemo(
() => (Array.isArray(accept) ? accept : [accept]),
[accept],
);
const dropValidator = useCallback(
(item: DatasourcePanelDndItem) =>
canDropProp(item) && (canDropValueProp?.(item.value) ?? true),
[canDropProp, canDropValueProp],
);
const [{ isOver, canDrop }, datasourcePanelDrop] = useDrop({
accept: isLoading ? [] : accept,
drop: (item: DatasourcePanelDndItem) => {
props.onDrop(item);
props.onDropValue?.(item.value);
const { setNodeRef, isOver, active } = useDroppable({
id: `dropzone-${props.name}`,
disabled: isLoading,
data: {
accept: acceptTypes,
// Mirrors react-dnd's `canDrop`: the drop only fires when this returns
// true, so duplicate/selection gating is preserved post-migration.
canDrop: dropValidator,
onDrop: props.onDrop,
onDropValue: props.onDropValue,
},
canDrop: dropValidator,
collect: monitor => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
type: monitor.getItemType(),
}),
});
const dispatch = useContext(DropzoneContext)[1];
// Check if the active dragged item can be dropped here
const canDrop = useMemo(() => {
if (!active?.data.current) return false;
const activeData = active.data.current as { type: string; value: unknown };
if (!acceptTypes.includes(activeData.type as DndItemType)) return false;
return dropValidator({
type: activeData.type as DndItemType,
value: activeData.value as DndItemValue,
});
}, [active, acceptTypes, dropValidator]);
const [, dispatch] = useContext(DropzoneContext);
useEffect(() => {
dispatch({ key: props.name, canDrop: dropValidator });
@@ -93,6 +114,15 @@ export default function DndSelectLabel({
const values = useMemo(() => valuesRenderer(), [valuesRenderer]);
// Generate sortable item IDs for SortableContext
const sortableItemIds = useMemo(() => {
if (!sortableType || itemCount === 0) return [];
return Array.from(
{ length: itemCount },
(_, i) => `sortable-${sortableType}-${i}`,
);
}, [sortableType, itemCount]);
function renderGhostButton() {
return (
<AddControlLabel
@@ -105,8 +135,25 @@ export default function DndSelectLabel({
);
}
// The actual drop is handled in ExploreDndContext's onDragEnd.
// Wrap values in SortableContext if sortable
const renderSortableValues = () => {
if (sortableItemIds.length > 0) {
return (
<SortableContext
items={sortableItemIds}
strategy={verticalListSortingStrategy}
>
{values}
</SortableContext>
);
}
return values;
};
return (
<div ref={datasourcePanelDrop}>
<div ref={setNodeRef}>
<HeaderContainer>
<ControlHeader {...props} />
</HeaderContainer>
@@ -117,7 +164,7 @@ export default function DndSelectLabel({
isDragging={isDragging}
isLoading={isLoading}
>
{values}
{renderSortableValues()}
{displayGhostButton && renderGhostButton()}
</DndLabelsContainer>
</div>

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
import { render, screen } from 'spec/helpers/testing-library';
import { DndItemType } from 'src/explore/components/DndItemType';
import OptionWrapper from 'src/explore/components/controls/DndColumnSelectControl/OptionWrapper';
@@ -29,35 +29,66 @@ test('renders with default props', async () => {
onShiftOptions={jest.fn()}
label="Option"
/>,
{ useDnd: true },
{ useDndKit: true },
);
expect(container).toBeInTheDocument();
expect(await screen.findByRole('img', { name: 'close' })).toBeInTheDocument();
});
test('triggers onShiftOptions on drop', async () => {
const onShiftOptions = jest.fn();
test('renders label correctly', async () => {
render(
<OptionWrapper
index={1}
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={jest.fn()}
label="Test Label"
/>,
{ useDndKit: true },
);
expect(await screen.findByText('Test Label')).toBeInTheDocument();
});
test('renders multiple options', async () => {
render(
<>
<OptionWrapper
index={0}
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={jest.fn()}
label="Option 1"
/>
<OptionWrapper
index={1}
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={onShiftOptions}
label="Option 1"
/>
<OptionWrapper
index={2}
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={onShiftOptions}
onShiftOptions={jest.fn()}
label="Option 2"
/>
</>,
{ useDnd: true },
{ useDndKit: true },
);
fireEvent.dragStart(await screen.findByText('Option 1'));
fireEvent.drop(await screen.findByText('Option 2'));
expect(onShiftOptions).toHaveBeenCalled();
expect(await screen.findByText('Option 1')).toBeInTheDocument();
expect(await screen.findByText('Option 2')).toBeInTheDocument();
});
test('calls clickClose when close button is clicked', async () => {
const clickClose = jest.fn();
render(
<OptionWrapper
index={1}
clickClose={clickClose}
type={'Column' as DndItemType}
onShiftOptions={jest.fn()}
label="Option"
/>,
{ useDndKit: true },
);
const closeButton = await screen.findByRole('img', { name: 'close' });
closeButton.click();
expect(clickClose).toHaveBeenCalledWith(1);
});

View File

@@ -16,13 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useRef } from 'react';
import {
useDrag,
useDrop,
DropTargetMonitor,
DragSourceMonitor,
} from 'react-dnd';
import { useRef, useMemo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { DragContainer } from 'src/explore/components/controls/OptionControls';
import {
OptionProps,
@@ -64,62 +60,32 @@ export default function OptionWrapper(
multiValueWarningMessage,
...rest
} = props;
const ref = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const [{ isDragging }, drag] = useDrag({
item: {
// Create a unique sortable ID for this item
const sortableId = useMemo(() => `sortable-${type}-${index}`, [type, index]);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: sortableId,
data: {
type,
dragIndex: index,
},
collect: (monitor: DragSourceMonitor) => ({
isDragging: monitor.isDragging(),
}),
onShiftOptions,
} as OptionItemInterface & { onShiftOptions: typeof onShiftOptions },
});
const [, drop] = useDrop({
accept: type,
hover: (item: OptionItemInterface, monitor: DropTargetMonitor) => {
if (!ref.current) {
return;
}
const { dragIndex } = item;
const hoverIndex = index;
// Don't replace items with themselves
if (dragIndex === hoverIndex) {
return;
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect();
// Get vertical middle
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = clientOffset
? clientOffset.y - hoverBoundingRect.top
: 0;
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
// When dragging upwards, only move when the cursor is above 50%
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
// Time to actually perform the action
onShiftOptions(dragIndex, hoverIndex);
// eslint-disable-next-line no-param-reassign
item.dragIndex = hoverIndex;
},
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const shouldShowTooltip =
(!isDragging && tooltipTitle && label && tooltipTitle !== label) ||
@@ -179,10 +145,14 @@ export default function OptionWrapper(
return null;
};
drag(drop(ref));
return (
<DragContainer ref={ref} {...rest}>
<DragContainer
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
{...rest}
>
<Option
index={index}
clickClose={clickClose}

View File

@@ -0,0 +1,120 @@
/**
* 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 {
DroppableData,
resolveDragEnd,
} from 'src/explore/components/ExploreContainer/ExploreDndContext';
import { DndItemType } from 'src/explore/components/DndItemType';
import { DndItemValue } from 'src/explore/components/DatasourcePanel/types';
/**
* @dnd-kit's PointerSensor only reacts to real pointer events, which jsdom
* cannot meaningfully dispatch (it has no layout). To exercise drop behavior in
* unit tests we capture the `data` object a control registers via
* `useDroppable` and feed it through the same `resolveDragEnd` dispatcher the
* live `ExploreDndContextProvider` runs on drag end.
*
* Usage: spy on `useDroppable` with `captureDroppableData`, render the control,
* then call `simulateDrop` with the dragged item.
*/
export type CapturedDroppable = { current: DroppableData | undefined };
/**
* Returns a `jest.fn` mock implementation for `@dnd-kit/core`'s `useDroppable`
* that records the most recently registered droppable data into `captured`,
* while returning an inert droppable shape so the control still renders.
*/
export function captureDroppableData(captured: CapturedDroppable) {
return (args: { data?: DroppableData }) => {
captured.current = args?.data;
return {
setNodeRef: () => {},
isOver: false,
active: null,
rect: { current: null },
node: { current: null },
over: null,
};
};
}
/**
* Drives a single drag-and-drop of `item` onto the captured droppable through
* the production `resolveDragEnd` dispatcher.
*/
export function simulateDrop(
captured: CapturedDroppable,
item: { type: DndItemType; value: DndItemValue },
) {
resolveDragEnd(
{ id: 'drag-source', data: { current: item } },
{ id: 'dropzone', data: { current: captured.current ?? {} } },
);
}
export type SortableItemData = {
type: string;
dragIndex: number;
onShiftOptions?: (dragIndex: number, hoverIndex: number) => void;
onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
onDropLabel?: () => void;
};
export type CapturedSortables = { items: SortableItemData[] };
/**
* Returns a `jest.fn` implementation for `@dnd-kit/sortable`'s `useSortable`
* that records each sortable item's registered data (carrying the reorder
* callbacks) into `captured`, while returning an inert sortable shape so the
* control still renders.
*/
export function captureSortableData(captured: CapturedSortables) {
return (args: { data?: SortableItemData }) => {
if (args?.data) {
captured.items.push(args.data);
}
return {
attributes: {},
listeners: {},
setNodeRef: () => {},
transform: null,
transition: undefined,
isDragging: false,
setActivatorNodeRef: () => {},
};
};
}
/**
* Drives an in-list reorder (drag item at `fromIndex` over item at `toIndex`)
* through the production `resolveDragEnd` dispatcher, using the reorder
* callbacks the control registered on its sortable items.
*/
export function simulateReorder(
captured: CapturedSortables,
fromIndex: number,
toIndex: number,
) {
const from = captured.items.find(i => i.dragIndex === fromIndex);
const to = captured.items.find(i => i.dragIndex === toIndex);
resolveDragEnd(
{ id: `from-${fromIndex}`, data: { current: from } },
{ id: `to-${toIndex}`, data: { current: to } },
);
}

View File

@@ -73,7 +73,7 @@ test('should render the control label', async () => {
test('should render the remove button', async () => {
render(setup(mockedProps), { useDnd: true, useRedux: true });
const removeBtn = await screen.findByRole('button');
const removeBtn = await screen.findByTestId('remove-control-button');
expect(removeBtn).toBeInTheDocument();
});

View File

@@ -16,12 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
render,
screen,
fireEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import {
OptionControlLabel,
DragContainer,
@@ -48,7 +43,7 @@ const defaultProps = {
const setup = (overrides?: Record<string, any>) =>
render(<OptionControlLabel {...defaultProps} {...overrides} />, {
useDnd: true,
useDndKit: true,
});
test('should render', async () => {
@@ -88,7 +83,7 @@ test('should display a certification icon if saved metric is certified', async (
);
});
test('triggers onMoveLabel on drop', async () => {
test('renders multiple labels', async () => {
render(
<>
<OptionControlLabel
@@ -101,15 +96,11 @@ test('triggers onMoveLabel on drop', async () => {
index={2}
label={<span>Label 2</span>}
/>
,
</>,
{ useDnd: true },
{ useDndKit: true },
);
await waitFor(() => {
fireEvent.dragStart(screen.getByText('Label 1'));
fireEvent.drop(screen.getByText('Label 2'));
expect(defaultProps.onMoveLabel).toHaveBeenCalled();
});
expect(await screen.findByText('Label 1')).toBeInTheDocument();
expect(await screen.findByText('Label 2')).toBeInTheDocument();
});
test('renders DragContainer', () => {

View File

@@ -16,9 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useRef, ReactNode } from 'react';
import { useDrag, useDrop, DropTargetMonitor } from 'react-dnd';
import { useRef, ReactNode, useMemo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { t } from '@apache-superset/core/translation';
import { styled, useTheme, css, keyframes } from '@apache-superset/core/theme';
import { InfoTooltip, Icons, Tooltip } from '@superset-ui/core/components';
@@ -233,9 +233,12 @@ export const AddIconButton = styled.button`
}
`;
interface DragItem {
dragIndex: number;
export interface SortableItemData {
type: string;
dragIndex: number;
onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
onDropLabel?: () => void;
value?: savedMetricType | AdhocMetric;
}
export const OptionControlLabel = ({
@@ -272,73 +275,37 @@ export const OptionControlLabel = ({
multi?: boolean;
}) => {
const theme = useTheme();
const ref = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const hasMetricName = savedMetric?.metric_name;
const [, drop] = useDrop({
accept: type,
drop() {
if (!multi) {
return;
}
onDropLabel?.();
},
hover(item: DragItem, monitor: DropTargetMonitor) {
if (!multi) {
return;
}
if (!ref.current) {
return;
}
const { dragIndex } = item;
const hoverIndex = index;
// Don't replace items with themselves
if (dragIndex === hoverIndex) {
return;
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect();
// Get vertical middle
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = clientOffset?.y
? clientOffset?.y - hoverBoundingRect.top
: 0;
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
// When dragging upwards, only move when the cursor is above 50%
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
// Time to actually perform the action
onMoveLabel?.(dragIndex, hoverIndex);
// Note: we're mutating the monitor item here!
// Generally it's better to avoid mutations,
// but it's good here for the sake of performance
// to avoid expensive index searches.
// eslint-disable-next-line no-param-reassign
item.dragIndex = hoverIndex;
},
});
const [{ isDragging }, drag] = useDrag({
item: {
// Create a unique sortable ID for this item
const sortableId = useMemo(() => `sortable-${type}-${index}`, [type, index]);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: sortableId,
disabled: !multi,
data: {
type,
dragIndex: index,
onMoveLabel,
onDropLabel,
value: savedMetric?.metric_name ? savedMetric : adhocMetric,
},
collect: monitor => ({
isDragging: monitor.isDragging(),
}),
} as SortableItemData,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const getLabelContent = () => {
const shouldShowTooltip =
(!isDragging &&
@@ -423,6 +390,14 @@ export const OptionControlLabel = ({
</OptionControlContainer>
);
drag(drop(ref));
return <DragContainer ref={ref}>{getOptionControlContent()}</DragContainer>;
return (
<DragContainer
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
>
{getOptionControlContent()}
</DragContainer>
);
};

View File

@@ -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();
});

View File

@@ -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);

View File

@@ -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();
});

View File

@@ -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);
}
}
/**

View File

@@ -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,
},
);

View File

@@ -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) {

View 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;
}
}

View File

@@ -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} />);

View File

@@ -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>

View File

@@ -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 => ({

Some files were not shown because too many files have changed in this diff Show More