mirror of
https://github.com/apache/superset.git
synced 2026-06-11 02:29:19 +00:00
Compare commits
81 Commits
fix/chart-
...
claude/sub
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcb96b771a | ||
|
|
9f916c973c | ||
|
|
a488b8894d | ||
|
|
49c87d05e8 | ||
|
|
2c63525d4b | ||
|
|
0977f624b8 | ||
|
|
286258fc36 | ||
|
|
4874f9c562 | ||
|
|
36f0b0595d | ||
|
|
ed71e259cb | ||
|
|
58e0232a31 | ||
|
|
a820043a61 | ||
|
|
d60ce8c3ef | ||
|
|
d70282d2d2 | ||
|
|
bcd52ceb38 | ||
|
|
757fe5b5b8 | ||
|
|
eaa809e6e3 | ||
|
|
af0f1b64fe | ||
|
|
84ec1c9750 | ||
|
|
3dae9d857a | ||
|
|
1d69cd9f0b | ||
|
|
797d6ee5a3 | ||
|
|
aed8cc439e | ||
|
|
8e50e460da | ||
|
|
2444445ed0 | ||
|
|
024cab4ac2 | ||
|
|
066d101b82 | ||
|
|
d0d9fb257b | ||
|
|
5fee21b509 | ||
|
|
16b6eb5e4d | ||
|
|
6274408b92 | ||
|
|
54085f4de3 | ||
|
|
7654c74978 | ||
|
|
3922c7fc1e | ||
|
|
8dc9f81a97 | ||
|
|
5b5f98af20 | ||
|
|
1bc20f2206 | ||
|
|
7e093a2e2a | ||
|
|
963312ee45 | ||
|
|
8692b6e817 | ||
|
|
fbd07afdc9 | ||
|
|
9141324715 | ||
|
|
1613e53aaf | ||
|
|
8a609fd83d | ||
|
|
ec7e76eb6c | ||
|
|
89919a1c96 | ||
|
|
9389a2a348 | ||
|
|
d8335c0e1b | ||
|
|
47d3425064 | ||
|
|
bb57efaa53 | ||
|
|
b2200cb740 | ||
|
|
6e3c21d3a3 | ||
|
|
28b845ead4 | ||
|
|
c531185d0a | ||
|
|
b7c4d1e999 | ||
|
|
7daa1741d0 | ||
|
|
d26802bcb3 | ||
|
|
a639285944 | ||
|
|
9c4bbc6c2f | ||
|
|
9e94723ef7 | ||
|
|
6697e69468 | ||
|
|
32b02842ae | ||
|
|
3275147a07 | ||
|
|
04b6429597 | ||
|
|
5ed2a403a5 | ||
|
|
e0b51e4bf8 | ||
|
|
15ed72878b | ||
|
|
197bd912eb | ||
|
|
1f42ee1bb5 | ||
|
|
bbe9fb2d12 | ||
|
|
3d5002c622 | ||
|
|
a93081940f | ||
|
|
57cc9a671f | ||
|
|
ea9199156e | ||
|
|
5c0d2bfc5b | ||
|
|
f8b26caf9d | ||
|
|
0bd3d3a06b | ||
|
|
7aee4fb7bd | ||
|
|
7ca048a0eb | ||
|
|
438031cbc4 | ||
|
|
88231f2b41 |
16
UPDATING.md
16
UPDATING.md
@@ -24,6 +24,22 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
- [39925](https://github.com/apache/superset/pull/39925): URL prefixing for `SUPERSET_APP_ROOT` subdirectory deployments is now handled automatically by helpers in `src/utils/navigationUtils` (`openInNewTab`, `redirect`, `getShareableUrl`, `<AppLink>`). Direct imports of `ensureAppRoot` / `makeUrl` from `src/utils/pathUtils` are forbidden outside `navigationUtils.ts` (enforced by a static-invariant test); contributors writing new code should use the focused helpers instead. No runtime behaviour change for existing callers — all 19 prior call sites have been migrated and four pre-existing double-prefix and missing-prefix bugs are fixed as part of the migration.
|
||||
|
||||
- **Breaking — `Superset` view class route prefix removed.** The `Superset` view in `superset/views/core.py` now declares `route_base = ""`, overriding Flask-AppBuilder's auto-derived `/superset` prefix. Routes that previously lived at `/superset/welcome/`, `/superset/dashboard/<id>/`, `/superset/dashboard/p/<key>/`, `/superset/explore/`, etc. now respond at `/welcome/`, `/dashboard/<id>/`, `/dashboard/p/<key>/`, `/explore/`, etc. Under subdirectory deployment (`SUPERSET_APP_ROOT=/superset`) the URLs are unchanged from end-user perspective — `AppRootMiddleware` re-applies the prefix via `SCRIPT_NAME`. Under root deployments, any external integration or bookmark that hard-codes `/superset/<endpoint>/` paths must be updated to drop the prefix. This fixes the doubled `/superset/superset/...` URLs that `url_for` emitted for these endpoints under subdirectory deployment and the related 404s on the routes themselves.
|
||||
|
||||
- **Breaking — Three sibling view classes route prefix removed.** Following the same rationale as the `Superset` class above, `ExplorePermalinkView` (`superset/views/explore.py`), `TagModelView`, and `TaggedObjectsModelView` (`superset/views/tags.py`, `superset/views/all_entities.py`) now mount at the application root rather than a hard-coded `/superset/...`. The user-visible URLs `/superset/explore/p/<key>/`, `/superset/tags/`, and `/superset/all_entities/` are unchanged under subdirectory deployment; under root deployments these views now serve `/explore/p/<key>/`, `/tags/`, and `/all_entities/`, so any external integration or bookmark must drop the `/superset/` prefix. `Dashboard.url` and `Dashboard.get_url` likewise return `/dashboard/<id>/` instead of the prior `/superset/dashboard/<id>/` literal so downstream consumers (DashboardList row hrefs, MCP service `dashboard_url`) emit a single, deployment-correct prefix.
|
||||
|
||||
- **Legacy `/superset/*` path support.** A new outermost WSGI middleware `LegacyPrefixRedirectMiddleware` (`superset/middleware/legacy_prefix_redirect.py`) 308-redirects every enumerated legacy `/superset/<canonical>` path to its post-`route_base=""` canonical location (e.g. `/superset/welcome/` → `/welcome/` under root; → `/superset/welcome/` under `SUPERSET_APP_ROOT=/superset`, because the canonical resolves through `AppRootMiddleware`). Bookmarks, email links, and external integrations survive the route-base collapse for one release cycle. POST against a GET-only canonical returns 410 Gone instead of 308 (308 would 405 on retry). The shim is removed at EOL `5.0.0`, matching the `@deprecated(eol_version="5.0.0")` gate on `Superset.explore` and `Superset.explore_json`.
|
||||
|
||||
- **PWA web app manifest served dynamically.** The PWA manifest is now served at `/pwa-manifest.json` (under `APPLICATION_ROOT`) by a new `PwaManifestView` (`superset/views/pwa_manifest.py`) instead of the static file at `/static/assets/pwa-manifest.json`. The legacy static source at `superset-frontend/src/pwa-manifest.json` has been removed (along with its `webpack.config.js` `CopyPlugin` rule). The new endpoint resolves `APPLICATION_ROOT` and `STATIC_ASSETS_PREFIX` at request time so PWA install works under subdirectory deployments and split static-prefix / app-root deployments (where `STATIC_ASSETS_PREFIX` points to a CDN host while the Superset backend stays under `APPLICATION_ROOT`). The `<link rel="manifest">` href in `superset/templates/superset/spa.html` was updated correspondingly (using a new `application_root_rstrip` template global). Operators with a forked `spa.html` should switch any manifest `<link>` to `{{ application_root_rstrip }}/pwa-manifest.json`.
|
||||
|
||||
- **Hard re-bookmark break — `/superset/sql/<database_id>/`.** SQL Lab moved to its own blueprint at `/sqllab/`. The legacy `/superset/sql/<id>/` shape changed to a query-string form (`/sqllab/?dbid=<id>`); no 1:1 path mapping exists, so `LegacyPrefixRedirectMiddleware` does **not** redirect this route — it passes through and surfaces a 404. Users with bookmarks to `/superset/sql/<id>/` must update them to `/sqllab/?dbid=<id>`.
|
||||
|
||||
- **`SqlaTable.sql_url` query-string format.** `SqlaTable.sql_url` now URL-encodes `table_name` and joins it as a query parameter rather than concatenating a second `?`. Previously, with `Database.sql_url` returning `/sqllab/?dbid=<id>`, the concatenation produced `/sqllab/?dbid=<id>?table_name=<raw>` — a malformed second `?` that broke the query parser. External code that parsed the legacy `<base>?table_name=<raw>` shape now sees properly percent-encoded values (e.g. `/` → `%2F`, ` ` → `+` or `%20`); decode with `urllib.parse.parse_qsl`.
|
||||
|
||||
- **New config flag `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE` (default `False`).** Share/permalink URLs now substitute `window.location.origin` for the backend-supplied origin so a proxied or subdirectory-deployed Superset never hands the user an unreachable internal hostname. Operators whose reverse proxy correctly forwards `X-Forwarded-Host` *and* who want permalinks to carry the backend's literal origin can opt out by setting `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE = True` in `superset_config.py`. Default `False` (rewrite is on); flipping the default would regress the dominant proxied/subdir deployment to an unreachable host.
|
||||
|
||||
### Duration formatter precision
|
||||
|
||||
The `DURATION` number formatter now uses `Intl.DurationFormat` for locale-aware output. By default, sub-second fields are omitted, so values that previously displayed fractional seconds with `pretty-ms`, such as `10500` milliseconds rendering as `10.5s`, now render as `10s`.
|
||||
|
||||
@@ -64,7 +64,7 @@ dependencies = [
|
||||
"holidays>=0.45, <1",
|
||||
"humanize",
|
||||
"isodate",
|
||||
"jsonpath-ng>=1.8.0, <2",
|
||||
"jsonpath-ng>=1.6.1, <2",
|
||||
"Mako>=1.2.2",
|
||||
"markdown>=3.10.2",
|
||||
# marshmallow>=4 has issues: https://github.com/apache/superset/issues/33162
|
||||
@@ -94,7 +94,7 @@ dependencies = [
|
||||
"PyJWT>=2.4.0, <3.0",
|
||||
"redis>=5.0.0, <6.0",
|
||||
"rison>=2.0.0, <3.0",
|
||||
"selenium>=4.44.0, <5.0",
|
||||
"selenium>=4.14.0, <5.0",
|
||||
"shillelagh[gsheetsapi]>=1.4.4, <2.0",
|
||||
"sshtunnel>=0.4.0, <0.5",
|
||||
"simplejson>=3.15.0",
|
||||
@@ -107,7 +107,7 @@ dependencies = [
|
||||
"typing-extensions>=4, <5",
|
||||
"waitress; sys_platform == 'win32'",
|
||||
"watchdog>=6.0.0",
|
||||
"wtforms>=3.2.2, <4",
|
||||
"wtforms>=2.3.3, <4",
|
||||
"wtforms-json",
|
||||
"xlsxwriter>=3.2.9, <3.3",
|
||||
]
|
||||
@@ -121,7 +121,7 @@ bigquery = [
|
||||
"sqlalchemy-bigquery>=1.15.0",
|
||||
"google-cloud-bigquery>=3.10.0",
|
||||
]
|
||||
clickhouse = ["clickhouse-connect>=1.1.1, <2.0"]
|
||||
clickhouse = ["clickhouse-connect>=0.13.0, <2.0"]
|
||||
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
|
||||
crate = ["sqlalchemy-cratedb>=0.41.0, <1"]
|
||||
d1 = [
|
||||
@@ -161,7 +161,7 @@ hive = [
|
||||
"pyhive[hive]>=0.6.5;python_version<'3.11'",
|
||||
"pyhive[hive_pure_sasl]>=0.7.0",
|
||||
"tableschema",
|
||||
"thrift>=0.23.0, <1.0.0",
|
||||
"thrift>=0.14.1, <1.0.0",
|
||||
"thrift_sasl>=0.4.3, < 1.0.0",
|
||||
]
|
||||
impala = ["impyla>0.16.2, <0.23"]
|
||||
@@ -195,7 +195,7 @@ spark = [
|
||||
"pyhive[hive]>=0.6.5;python_version<'3.11'",
|
||||
"pyhive[hive_pure_sasl]>=0.7",
|
||||
"tableschema",
|
||||
"thrift>=0.23.0, <1",
|
||||
"thrift>=0.14.1, <1",
|
||||
]
|
||||
tdengine = [
|
||||
"taospy>=2.7.21",
|
||||
|
||||
@@ -50,7 +50,7 @@ cattrs==25.1.1
|
||||
# via requests-cache
|
||||
celery==5.5.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
certifi==2026.5.20
|
||||
certifi==2025.6.15
|
||||
# via
|
||||
# requests
|
||||
# selenium
|
||||
@@ -194,7 +194,7 @@ jinja2==3.1.6
|
||||
# via
|
||||
# flask
|
||||
# flask-babel
|
||||
jsonpath-ng==1.8.0
|
||||
jsonpath-ng==1.7.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
jsonschema==4.23.0
|
||||
# via
|
||||
@@ -286,6 +286,8 @@ pillow==12.2.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
platformdirs==4.3.8
|
||||
# via requests-cache
|
||||
ply==3.11
|
||||
# via jsonpath-ng
|
||||
polyline==2.0.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
prison==0.2.1
|
||||
@@ -378,7 +380,7 @@ rpds-py==0.25.0
|
||||
# referencing
|
||||
rsa==4.9.1
|
||||
# via google-auth
|
||||
selenium==4.44.0
|
||||
selenium==4.32.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
setuptools==80.9.0
|
||||
# via -r requirements/base.in
|
||||
@@ -421,7 +423,7 @@ sshtunnel==0.4.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
tabulate==0.10.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
trio==0.33.0
|
||||
trio==0.30.0
|
||||
# via
|
||||
# selenium
|
||||
# trio-websocket
|
||||
@@ -478,7 +480,7 @@ wrapt==1.17.2
|
||||
# via deprecated
|
||||
wsproto==1.2.0
|
||||
# via trio-websocket
|
||||
wtforms==3.2.2
|
||||
wtforms==3.2.1
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
|
||||
@@ -112,7 +112,7 @@ celery==5.5.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
certifi==2026.5.20
|
||||
certifi==2025.6.15
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# httpcore
|
||||
@@ -471,7 +471,7 @@ jmespath==1.1.0
|
||||
# via
|
||||
# boto3
|
||||
# botocore
|
||||
jsonpath-ng==1.8.0
|
||||
jsonpath-ng==1.7.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -674,6 +674,10 @@ platformdirs==4.3.8
|
||||
# virtualenv
|
||||
pluggy==1.5.0
|
||||
# via pytest
|
||||
ply==3.11
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# jsonpath-ng
|
||||
polib==1.2.0
|
||||
# via apache-superset
|
||||
polyline==2.0.2
|
||||
@@ -921,7 +925,7 @@ s3transfer==0.16.0
|
||||
# via boto3
|
||||
secretstorage==3.5.0
|
||||
# via keyring
|
||||
selenium==4.44.0
|
||||
selenium==4.32.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -1019,7 +1023,7 @@ tqdm==4.67.1
|
||||
# prophet
|
||||
trino==0.330.0
|
||||
# via apache-superset
|
||||
trio==0.33.0
|
||||
trio==0.30.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# selenium
|
||||
@@ -1121,7 +1125,7 @@ wsproto==1.2.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# trio-websocket
|
||||
wtforms==3.2.2
|
||||
wtforms==3.2.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -48,7 +48,6 @@ module.exports = {
|
||||
'@babel/plugin-syntax-dynamic-import',
|
||||
'@babel/plugin-transform-export-namespace-from',
|
||||
['@babel/plugin-transform-class-properties', { loose: true }],
|
||||
'@babel/plugin-transform-class-static-block',
|
||||
['@babel/plugin-transform-optional-chaining', { loose: true }],
|
||||
['@babel/plugin-transform-private-methods', { loose: true }],
|
||||
['@babel/plugin-transform-nullish-coalescing-operator', { loose: true }],
|
||||
|
||||
@@ -19,9 +19,8 @@
|
||||
|
||||
export const DASHBOARD_LIST = '/dashboard/list/';
|
||||
export const CHART_LIST = '/chart/list/';
|
||||
export const WORLD_HEALTH_DASHBOARD = '/superset/dashboard/world_health/';
|
||||
export const SAMPLE_DASHBOARD_1 = '/superset/dashboard/1-sample-dashboard/';
|
||||
export const SUPPORTED_CHARTS_DASHBOARD =
|
||||
'/superset/dashboard/supported_charts_dash/';
|
||||
export const TABBED_DASHBOARD = '/superset/dashboard/tabbed_dash/';
|
||||
export const WORLD_HEALTH_DASHBOARD = '/dashboard/world_health/';
|
||||
export const SAMPLE_DASHBOARD_1 = '/dashboard/1-sample-dashboard/';
|
||||
export const SUPPORTED_CHARTS_DASHBOARD = '/dashboard/supported_charts_dash/';
|
||||
export const TABBED_DASHBOARD = '/dashboard/tabbed_dash/';
|
||||
export const DATABASE_LIST = '/databaseview/list';
|
||||
|
||||
234
superset-frontend/package-lock.json
generated
234
superset-frontend/package-lock.json
generated
@@ -95,7 +95,7 @@
|
||||
"echarts": "^5.6.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.3.5",
|
||||
"fuse.js": "^7.4.1",
|
||||
"fuse.js": "^7.3.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.6.0",
|
||||
"geostyler-data": "^1.1.0",
|
||||
@@ -178,13 +178,13 @@
|
||||
"@babel/types": "^7.29.7",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/jest": "^11.14.2",
|
||||
"@formatjs/intl-durationformat": "^0.10.13",
|
||||
"@formatjs/intl-durationformat": "^0.10.3",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.4.2",
|
||||
"@storybook/addon-links": "10.4.2",
|
||||
"@storybook/react-webpack5": "10.4.2",
|
||||
"@storybook/addon-docs": "10.4.1",
|
||||
"@storybook/addon-links": "10.4.1",
|
||||
"@storybook/react-webpack5": "10.4.1",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.40",
|
||||
@@ -242,7 +242,7 @@
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.4",
|
||||
"eslint-plugin-storybook": "10.4.2",
|
||||
"eslint-plugin-storybook": "10.4.1",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -272,7 +272,7 @@
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.4.2",
|
||||
"storybook": "10.4.1",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
@@ -3939,38 +3939,50 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/bigdecimal": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/bigdecimal/-/bigdecimal-0.2.5.tgz",
|
||||
"integrity": "sha512-2XTKNrZRaCUyXK2976wfutqxMBuPO/S/zbJnQdysLI2Zy5mWPVNVEkE6tsTcSVWSE7DgO88t8DtBy+uf3I8bxg==",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/bigdecimal/-/bigdecimal-0.2.0.tgz",
|
||||
"integrity": "sha512-GeaxHZbUoYvHL9tC5eltHLs+1zU70aPw0s7LwqgktIzF5oMhNY4o4deEtusJMsq7WFJF3Ye2zQEzdG8beVk73w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@formatjs/ecma402-abstract": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-3.2.0.tgz",
|
||||
"integrity": "sha512-dHnqHgBo6GXYGRsepaE1wmsC2etaivOWd5VaJstZd+HI2zR3DCUjbDVZRtoPGkkXZmyHvBwrdEUuqfvzhF/DtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/bigdecimal": "0.2.0",
|
||||
"@formatjs/fast-memoize": "3.1.1",
|
||||
"@formatjs/intl-localematcher": "0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/fast-memoize": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.5.tgz",
|
||||
"integrity": "sha512-KLi3fan6WnCHmigd9pmEEN8Hid0v4wiFBW576M/d07KMWYecf1CvyMI3n34vCmHT4AoVqG2n702kiHbXjzZX2A==",
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.1.tgz",
|
||||
"integrity": "sha512-CbNbf+tlJn1baRnPkNePnBqTLxGliG6DDgNa/UtV66abwIjwsliPMOt0172tzxABYzSuxZBZfcp//qI8AvBWPg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@formatjs/intl-durationformat": {
|
||||
"version": "0.10.13",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-durationformat/-/intl-durationformat-0.10.13.tgz",
|
||||
"integrity": "sha512-A1dBcOh1YrcRf/AbmZHFVXgIYkpAaFgyGaYavO/KutbqEXY3HI63o2E1ctmxmllfg3qn3TZGtZux42EFwHNTbg==",
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-durationformat/-/intl-durationformat-0.10.3.tgz",
|
||||
"integrity": "sha512-xRS3GaOlsQLwz0n56SvaddwEnl2NLPKBvYg2M32ak/27dodmVxFJz3j7Nqj7EwKyHTu3f/e+BeoKPrIDUSXTuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/bigdecimal": "0.2.5",
|
||||
"@formatjs/intl-localematcher": "0.8.9"
|
||||
"@formatjs/ecma402-abstract": "3.2.0",
|
||||
"@formatjs/intl-localematcher": "0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/intl-localematcher": {
|
||||
"version": "0.8.9",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.9.tgz",
|
||||
"integrity": "sha512-GmB0F/gYh4Hdl4rLWjgDsgT+x4pB54fkJeRh8kAZ4XFzKeCK8dGs+SBJWXO42QZtOUni+IDWKNuCw6wiL4lTvw==",
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.2.tgz",
|
||||
"integrity": "sha512-q05KMYGJLyqFNFtIb8NhWLF5X3aK/k0wYt7dnRFuy6aLQL+vUwQ1cg5cO4qawEiINybeCPXAWlprY2mSBjSXAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/fast-memoize": "3.1.5"
|
||||
"@formatjs/fast-memoize": "3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@gar/promise-retry": {
|
||||
@@ -9684,16 +9696,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/addon-docs": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.2.tgz",
|
||||
"integrity": "sha512-CtW1O4xSKZPNtpWgpfp4yB/x4pj/of+3MvlEDfErSlr3Hp3QmEa2pCLaecR08H5LJqJFlt1PtG0UrIynTvgW9w==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.1.tgz",
|
||||
"integrity": "sha512-IYqUdjoZe4VO2LFZlKL/gwy7DsQSWCq6hX+zc1MBmZo04yycDASk1tte57n9pdlW3ajw9yYMF/+lVBi+xQjyvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"@storybook/csf-plugin": "10.4.2",
|
||||
"@storybook/csf-plugin": "10.4.1",
|
||||
"@storybook/icons": "^2.0.2",
|
||||
"@storybook/react-dom-shim": "10.4.2",
|
||||
"@storybook/react-dom-shim": "10.4.1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"ts-dedent": "^2.0.0"
|
||||
@@ -9704,7 +9716,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.4.2"
|
||||
"storybook": "^10.4.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -9712,10 +9724,45 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-docs/node_modules/@storybook/csf-plugin": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.4.1.tgz",
|
||||
"integrity": "sha512-WdPepGBxDGOUDjYd8KxMtcf+us/2PAcnBczl77XtrnxxHNs0jWesxKkiJ9yiuGrge4BPhDeAj6rxjbBoaHxLBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"unplugin": "^2.3.5"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"esbuild": "*",
|
||||
"rollup": "*",
|
||||
"storybook": "^10.4.1",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"rollup": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
},
|
||||
"webpack": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-links": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.4.2.tgz",
|
||||
"integrity": "sha512-cU8h4/m+oAr8UUwF4teZG2N1ilV+vU+98Ii/Ma+IIx9M/V7i5544UxfAz84dV5Rx2Oho6x8XH3gIvmevSyPi/Q==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.4.1.tgz",
|
||||
"integrity": "sha512-h/5D23GwMuHA55sB7XDyhByF9psF7UFmaQOn72pjNAarew5eOpue5A+jXk3AKEYokHbvgQaoz+FrvWo9GEfSKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9728,7 +9775,7 @@
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.4.2"
|
||||
"storybook": "^10.4.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -9740,13 +9787,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/builder-webpack5": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.4.2.tgz",
|
||||
"integrity": "sha512-nhmV0+nThCgy1y5742SS7c4vJrd5/1KfCXCNfsJ1v4Rkq7NIQnUhEIBwkSaY63lqH7FRHlFxIjwGS63veiCJuw==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.4.1.tgz",
|
||||
"integrity": "sha512-3Ah4jUjg8nEms/5JV6odtQj9+pQ1DT/04s/V6dZKThGdl85YTrYUZV5OTgbNxYbmQn/TwpWWjQlcW8ulpo2WBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/core-webpack": "10.4.2",
|
||||
"@storybook/core-webpack": "10.4.1",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.4.0",
|
||||
"cjs-module-lexer": "^1.2.3",
|
||||
"css-loader": "^7.1.2",
|
||||
@@ -9767,7 +9814,7 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "^10.4.2"
|
||||
"storybook": "^10.4.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -9776,9 +9823,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/core-webpack": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.4.2.tgz",
|
||||
"integrity": "sha512-qnYKMruU8lvI4yaq2PA9Gmxjrc7EZ3DRBI/cVKwEgOIREoxzr1F1IE7t7+325k9Phylue7E5rD3A7yjxeEKUyw==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.4.1.tgz",
|
||||
"integrity": "sha512-Wert/4ou5WRl8WYWWS8bBW7Lxa/ASMEuQ3EVuG3SITAtPNvKDKqTFBjZLx9eJSefkX6fJ3yG85FFUOPsv6GemQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9789,42 +9836,7 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "^10.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/csf-plugin": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.4.2.tgz",
|
||||
"integrity": "sha512-GqX/2DeF3/jKs5D7gpDiuT9gd0c/f2TKcnQ5av4/s3YqeN+0nhm7btkCrDfgF16uzE1Zj3OrkxvB3AOkfxWgDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"unplugin": "^2.3.5"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"esbuild": "*",
|
||||
"rollup": "*",
|
||||
"storybook": "^10.4.2",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"rollup": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
},
|
||||
"webpack": {
|
||||
"optional": true
|
||||
}
|
||||
"storybook": "^10.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/global": {
|
||||
@@ -9846,13 +9858,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/preset-react-webpack": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.4.2.tgz",
|
||||
"integrity": "sha512-21ld380f0/jTTitkfhTKgP3FBnVAgMu1P1ymrRyiFYJVSJBA5YejndFFBo0ugq9iGGsHXrVdOphC/OJKbTSWRQ==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.4.1.tgz",
|
||||
"integrity": "sha512-uAR/C/oDZYhReaYpD4Rd5S4VWcXP2XO8+BwXwanKt4UHbYfOw7AQgBTeZ/6Wns/0xIXhOoA1rxO5TA2wDLUjLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/core-webpack": "10.4.2",
|
||||
"@storybook/core-webpack": "10.4.1",
|
||||
"@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"magic-string": "^0.30.5",
|
||||
@@ -9869,7 +9881,7 @@
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.4.2"
|
||||
"storybook": "^10.4.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -9878,14 +9890,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.4.2.tgz",
|
||||
"integrity": "sha512-NfEH3CrdCAgUV4Z7SPN3Iw6nofcueqtRj8iHuo77GNjz0qSfuVi9iS7a8o7x7QFSeIBZwS0Jv3CgmhN8qvoLjg==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.4.1.tgz",
|
||||
"integrity": "sha512-WuYz4NaUk4gmFAMliSpCbV8w6jP5OY9juBfw1huwzu2S/k5FhnVXwmrUaL0fmf3Bq/7NgkzmBBbZr6I6LuHayQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/global": "^5.0.0",
|
||||
"@storybook/react-dom-shim": "10.4.2",
|
||||
"@storybook/react-dom-shim": "10.4.1",
|
||||
"react-docgen": "^8.0.2",
|
||||
"react-docgen-typescript": "^2.2.2"
|
||||
},
|
||||
@@ -9898,7 +9910,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.4.2",
|
||||
"storybook": "^10.4.1",
|
||||
"typescript": ">= 4.9.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -9978,9 +9990,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-dom-shim": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.4.2.tgz",
|
||||
"integrity": "sha512-Eng3Yt2NCjPX94QcfyLeUFhrMj0hec2yU9J/qafBVbfj9XrFI8o+0ZwYJ7uXb9ECbvPN4y06dgt/2W/LiR417w==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.4.1.tgz",
|
||||
"integrity": "sha512-6QFqfDNH4DMrt7yHKRfpqRopsVUc/Az+sXIdJ39IetYnHUxL3nW4NVaPc6uy/8Qi8urzUyEXL/nn7cpSIP2aPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -9992,7 +10004,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.4.2"
|
||||
"storybook": "^10.4.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10004,15 +10016,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.4.2.tgz",
|
||||
"integrity": "sha512-x7xwGLxU0w6/qi29/cHhua8qiCvfE05ku4pPLTXF8TsP/zfGsY8tbdlKO2+YKp+iBG8vafVc//ZXOAty1oypDA==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.4.1.tgz",
|
||||
"integrity": "sha512-2jF231DrEk70I8+wVakCnKtpweGFNfxdaov883Rve0TFvhxZs42Y9PpKzSf4rusvSrWc9jdWuJ2k7ERbS50MLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/builder-webpack5": "10.4.2",
|
||||
"@storybook/preset-react-webpack": "10.4.2",
|
||||
"@storybook/react": "10.4.2"
|
||||
"@storybook/builder-webpack5": "10.4.1",
|
||||
"@storybook/preset-react-webpack": "10.4.1",
|
||||
"@storybook/react": "10.4.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -10021,7 +10033,7 @@
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.4.2",
|
||||
"storybook": "^10.4.1",
|
||||
"typescript": ">= 4.9.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -19409,9 +19421,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-storybook": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.4.2.tgz",
|
||||
"integrity": "sha512-l3/vzLRmb8VSi3X1Bo6/Pa+64naw1jFsZE5jPPA4izvVdNhH1rF4rGuOC3kDTU926qKVBQtKua8D24XWQtvcGg==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.4.1.tgz",
|
||||
"integrity": "sha512-sLEvd/7lg/LtXwMjj3iFxZtoeAC/8l1Qhuw3Noa8iF8i0UIgAejUs7k6DNSqHkwrPR8caWT4+3fxdMXs1iGLTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -19419,7 +19431,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=8",
|
||||
"storybook": "^10.4.2"
|
||||
"storybook": "^10.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-testing-library": {
|
||||
@@ -20921,9 +20933,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fuse.js": {
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.4.1.tgz",
|
||||
"integrity": "sha512-AY7lKAXK71hi3WgUvDy6oZL67UEHOOtvCAwVdOXHyJd6ZzftBy7QqxuXt4HxmmAhYjmp/YCuOELZtIvAdlZ+fw==",
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.4.0.tgz",
|
||||
"integrity": "sha512-3UqmoSFwzX1sNB1YSk+Co0EdH29XCW2p9g48OAiy93cjKqzuABsqw2VIgSN3CmsT/wo6pIJ3F0Jxeiiby8rhIQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -39036,9 +39048,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/storybook": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.2.tgz",
|
||||
"integrity": "sha512-5Ax5vbHxFgMBGGhQDm75Rrumm/HZC4ICFhMcJaM0UlqnC/4FKj/IaZtImZFupknyiiyUEcWHPQFA2kX3/VSv1A==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.1.tgz",
|
||||
"integrity": "sha512-V1Zd2e+gBFufqAQVZ1JR8KLqALsEZ3JYSBnWwQbKa6zCfWWanR6AFMyuOkLt2gZOgGp3h2Riuz88pGNVTQSG0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -45363,7 +45375,7 @@
|
||||
"@deck.gl/extensions": "~9.2.9",
|
||||
"@deck.gl/geo-layers": "~9.2.5",
|
||||
"@deck.gl/layers": "~9.2.5",
|
||||
"@deck.gl/mapbox": "^9.3.3",
|
||||
"@deck.gl/mapbox": "~9.3.2",
|
||||
"@deck.gl/mesh-layers": "~9.2.5",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
@@ -45411,9 +45423,9 @@
|
||||
}
|
||||
},
|
||||
"plugins/preset-chart-deckgl/node_modules/@deck.gl/mapbox": {
|
||||
"version": "9.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.3.3.tgz",
|
||||
"integrity": "sha512-aUPqrwF6wkx+EtvKA3SaiK+UROMnZSmgEJWZ1qSKFSiH//kPuo5imbtXyan8sGhOet7NjnfEwJqFA3EBk7zDLA==",
|
||||
"version": "9.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.3.2.tgz",
|
||||
"integrity": "sha512-+T9pJwsOXwjUxyGN6oiBMfIs28VtDIG1V1Rqz4qqn4TjjNEFFw+xO0olJIg8FO5IAqw2OtePdsrMj0tX8tHdGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0"
|
||||
|
||||
@@ -178,7 +178,7 @@
|
||||
"echarts": "^5.6.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.3.5",
|
||||
"fuse.js": "^7.4.1",
|
||||
"fuse.js": "^7.3.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.6.0",
|
||||
"geostyler-data": "^1.1.0",
|
||||
@@ -261,13 +261,13 @@
|
||||
"@babel/types": "^7.29.7",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/jest": "^11.14.2",
|
||||
"@formatjs/intl-durationformat": "^0.10.13",
|
||||
"@formatjs/intl-durationformat": "^0.10.3",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.4.2",
|
||||
"@storybook/addon-links": "10.4.2",
|
||||
"@storybook/react-webpack5": "10.4.2",
|
||||
"@storybook/addon-docs": "10.4.1",
|
||||
"@storybook/addon-links": "10.4.1",
|
||||
"@storybook/react-webpack5": "10.4.1",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.40",
|
||||
@@ -325,7 +325,7 @@
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.4",
|
||||
"eslint-plugin-storybook": "10.4.2",
|
||||
"eslint-plugin-storybook": "10.4.1",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -355,7 +355,7 @@
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.4.2",
|
||||
"storybook": "10.4.1",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Disposable, Event } from '../common';
|
||||
import { Disposable } from '../common';
|
||||
|
||||
/**
|
||||
* Represents a menu item that links a view to a command.
|
||||
@@ -102,37 +102,3 @@ export declare function registerMenuItem(
|
||||
* ```
|
||||
*/
|
||||
export declare function getMenu(location: string): Menu | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when a menu item is registered.
|
||||
*/
|
||||
export interface MenuItemRegisteredEvent {
|
||||
/** The menu item that was registered. */
|
||||
item: MenuItem;
|
||||
/** The location where the item was registered. */
|
||||
location: string;
|
||||
/** The group the item was placed in. */
|
||||
group: 'primary' | 'secondary' | 'context';
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a menu item is unregistered.
|
||||
*/
|
||||
export interface MenuItemUnregisteredEvent {
|
||||
/** The menu item that was unregistered. */
|
||||
item: MenuItem;
|
||||
/** The location where the item was registered. */
|
||||
location: string;
|
||||
/** The group the item was placed in. */
|
||||
group: 'primary' | 'secondary' | 'context';
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a menu item is registered.
|
||||
*/
|
||||
export declare const onDidRegisterMenuItem: Event<MenuItemRegisteredEvent>;
|
||||
|
||||
/**
|
||||
* Event fired when a menu item is unregistered.
|
||||
*/
|
||||
export declare const onDidUnregisterMenuItem: Event<MenuItemUnregisteredEvent>;
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import { Disposable, Event } from '../common';
|
||||
import { Disposable } from '../common';
|
||||
|
||||
/**
|
||||
* Represents a contributed view in the application.
|
||||
@@ -88,33 +88,3 @@ export declare function registerView(
|
||||
* ```
|
||||
*/
|
||||
export declare function getViews(location: string): View[] | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when a view is registered.
|
||||
*/
|
||||
export interface ViewRegisteredEvent {
|
||||
/** The descriptor of the view that was registered. */
|
||||
view: View;
|
||||
/** The location where the view was registered. */
|
||||
location: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a view is unregistered.
|
||||
*/
|
||||
export interface ViewUnregisteredEvent {
|
||||
/** The descriptor of the view that was unregistered. */
|
||||
view: View;
|
||||
/** The location where the view was registered. */
|
||||
location: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a view is registered.
|
||||
*/
|
||||
export declare const onDidRegisterView: Event<ViewRegisteredEvent>;
|
||||
|
||||
/**
|
||||
* Event fired when a view is unregistered.
|
||||
*/
|
||||
export declare const onDidUnregisterView: Event<ViewUnregisteredEvent>;
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.8",
|
||||
"dompurify": "^3.4.7",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"jed": "^1.1.1",
|
||||
|
||||
@@ -109,7 +109,7 @@ export default class ChartClient {
|
||||
(await buildQueryRegistry.get(visType)) ?? (() => formData);
|
||||
const requestConfig: RequestConfig = useLegacyApi
|
||||
? {
|
||||
endpoint: '/superset/explore_json/',
|
||||
endpoint: '/explore_json/',
|
||||
postPayload: {
|
||||
form_data: buildQuery(formData),
|
||||
},
|
||||
@@ -139,7 +139,7 @@ export default class ChartClient {
|
||||
): Promise<Datasource> {
|
||||
return this.client
|
||||
.get({
|
||||
endpoint: `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`,
|
||||
endpoint: `/fetch_datasource_metadata?datasourceKey=${datasourceKey}`,
|
||||
...options,
|
||||
} as RequestConfig)
|
||||
.then(response => response.json as Datasource);
|
||||
|
||||
@@ -262,9 +262,7 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
if (!useLegacyApi && !queryContext.queries) {
|
||||
queryContext = { queries: [queryContext] };
|
||||
}
|
||||
const endpoint = useLegacyApi
|
||||
? '/superset/explore_json/'
|
||||
: '/api/v1/chart/data';
|
||||
const endpoint = useLegacyApi ? '/explore_json/' : '/api/v1/chart/data';
|
||||
|
||||
const requestConfig: RequestConfig = {
|
||||
endpoint,
|
||||
|
||||
@@ -208,6 +208,14 @@ export default class SupersetClientClass {
|
||||
headers: { ...this.headers, ...headers },
|
||||
timeout: timeout ?? this.timeout,
|
||||
fetchRetryOptions: fetchRetryOptions ?? this.fetchRetryOptions,
|
||||
// Inbound normalisation seam (Slice 7, 2026-06-01): strip the configured
|
||||
// application root from router-relative URL fields in JSON responses so
|
||||
// outbound helpers (SupersetClient.getUrl, makeUrl, react-router
|
||||
// basename) don't re-prefix them into `/superset/superset/...`.
|
||||
// `@superset-ui/core` cannot import the app's `applicationRoot()`, so we
|
||||
// thread `this.appRoot` through `callApiAndParseWithTimeout` →
|
||||
// `parseResponse` here. Both `json` and `json-bigint` paths are covered.
|
||||
appRoot: this.appRoot,
|
||||
}).catch(res => {
|
||||
if (res?.status === 401 && !ignoreUnauthorized) {
|
||||
this.handleUnauthorized();
|
||||
@@ -276,8 +284,26 @@ export default class SupersetClientClass {
|
||||
const host = inputHost ?? this.host;
|
||||
const cleanHost = host.slice(-1) === '/' ? host.slice(0, -1) : host; // no backslash
|
||||
|
||||
// Strip a single leading appRoot segment so callers that accidentally
|
||||
// pre-prefix their endpoint (e.g. by wrapping with ensureAppRoot before
|
||||
// passing to the client) do not produce a doubled `/superset/superset/...`
|
||||
// URL. Single-pass strip (AF-5 reconciliation, 2026-06-01) mirrors
|
||||
// `stripAppRoot` in `src/utils/pathUtils` and `normalizeBackendUrlString`
|
||||
// exactly: a genuine `/superset/superset/<slug>` is a legitimate route, not
|
||||
// a double-prefix bug. The L2 static invariant still flags pre-prefixing as
|
||||
// a migration issue; this is the runtime safety net.
|
||||
let cleanEndpoint = endpoint;
|
||||
const root = this.appRoot;
|
||||
if (root) {
|
||||
if (cleanEndpoint === root) {
|
||||
cleanEndpoint = '';
|
||||
} else if (cleanEndpoint.startsWith(`${root}/`)) {
|
||||
cleanEndpoint = cleanEndpoint.slice(root.length);
|
||||
}
|
||||
}
|
||||
|
||||
return `${this.protocol}//${cleanHost}${this.appRoot}/${
|
||||
endpoint[0] === '/' ? endpoint.slice(1) : endpoint
|
||||
cleanEndpoint[0] === '/' ? cleanEndpoint.slice(1) : cleanEndpoint
|
||||
}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,14 @@ export default async function callApiAndParseWithTimeout<
|
||||
>({
|
||||
timeout,
|
||||
parseMethod,
|
||||
appRoot,
|
||||
...rest
|
||||
}: { timeout?: ClientTimeout; parseMethod?: T } & CallApi) {
|
||||
}: { timeout?: ClientTimeout; parseMethod?: T; appRoot?: string } & CallApi) {
|
||||
const apiPromise = callApi(rest);
|
||||
const racedPromise =
|
||||
typeof timeout === 'number' && timeout > 0
|
||||
? Promise.race([apiPromise, rejectAfterTimeout<Response>(timeout)])
|
||||
: apiPromise;
|
||||
|
||||
return parseResponse(racedPromise, parseMethod);
|
||||
return parseResponse(racedPromise, parseMethod, appRoot);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import _JSONbig from 'json-bigint';
|
||||
import { cloneDeepWith } from 'lodash';
|
||||
|
||||
import { ParseMethod, TextResponse, JsonResponse } from '../types';
|
||||
import { normalizeBackendUrls } from '../normalizeBackendUrls';
|
||||
|
||||
const JSONbig = _JSONbig({
|
||||
constructorAction: 'preserve',
|
||||
@@ -28,6 +29,7 @@ const JSONbig = _JSONbig({
|
||||
export default async function parseResponse<T extends ParseMethod = 'json'>(
|
||||
apiPromise: Promise<Response>,
|
||||
parseMethod?: T,
|
||||
appRoot?: string,
|
||||
) {
|
||||
type ReturnType = T extends 'raw' | null
|
||||
? Response
|
||||
@@ -55,24 +57,27 @@ export default async function parseResponse<T extends ParseMethod = 'json'>(
|
||||
if (parseMethod === 'json-bigint') {
|
||||
const rawData = await response.text();
|
||||
const json = JSONbig.parse(rawData);
|
||||
const decoded = cloneDeepWith(json, (value: any) => {
|
||||
if (
|
||||
value?.isInteger?.() === true &&
|
||||
(value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
|
||||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER))
|
||||
) {
|
||||
// toFixed() avoids scientific notation, which BigInt() rejects.
|
||||
return BigInt(value.toFixed());
|
||||
}
|
||||
// // `json-bigint` could not handle floats well, see sidorares/json-bigint#62
|
||||
// // TODO: clean up after json-bigint>1.0.1 is released
|
||||
if (value?.isNaN?.() === false) {
|
||||
return value?.toNumber?.();
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const result: JsonResponse = {
|
||||
response,
|
||||
json: cloneDeepWith(json, (value: any) => {
|
||||
if (
|
||||
value?.isInteger?.() === true &&
|
||||
(value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
|
||||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER))
|
||||
) {
|
||||
// toFixed() avoids scientific notation, which BigInt() rejects.
|
||||
return BigInt(value.toFixed());
|
||||
}
|
||||
// // `json-bigint` could not handle floats well, see sidorares/json-bigint#62
|
||||
// // TODO: clean up after json-bigint>1.0.1 is released
|
||||
if (value?.isNaN?.() === false) {
|
||||
return value?.toNumber?.();
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
json: appRoot
|
||||
? normalizeBackendUrls(decoded, { applicationRoot: appRoot })
|
||||
: decoded,
|
||||
};
|
||||
return result as ReturnType;
|
||||
}
|
||||
@@ -80,7 +85,9 @@ export default async function parseResponse<T extends ParseMethod = 'json'>(
|
||||
if (parseMethod === undefined || parseMethod === 'json') {
|
||||
const json = await response.json();
|
||||
const result: JsonResponse = {
|
||||
json,
|
||||
json: appRoot
|
||||
? normalizeBackendUrls(json, { applicationRoot: appRoot })
|
||||
: json,
|
||||
response,
|
||||
};
|
||||
return result as ReturnType;
|
||||
|
||||
@@ -21,6 +21,15 @@ export { default as callApi } from './callApi';
|
||||
export { default as SupersetClient } from './SupersetClient';
|
||||
export { default as SupersetClientClass } from './SupersetClientClass';
|
||||
|
||||
export {
|
||||
NORMALIZED_URL_FIELDS,
|
||||
NORMALIZER_EXCLUSIONS,
|
||||
NORMALIZE_MAX_DEPTH,
|
||||
normalizeBackendUrlString,
|
||||
normalizeBackendUrls,
|
||||
} from './normalizeBackendUrls';
|
||||
export type { NormalizeOptions } from './normalizeBackendUrls';
|
||||
|
||||
export * from './types';
|
||||
export * from './constants';
|
||||
export { default as __hack_reexport_connection } from './types';
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Strips the configured application root from URL fields in API responses so
|
||||
* the frontend always speaks router-relative paths. Without normalisation,
|
||||
* `SupersetClient` and `<Link>` would re-prefix backend-supplied URLs and
|
||||
* produce `/foo/foo/...`.
|
||||
*/
|
||||
|
||||
/** Field names known to be router-relative URLs to this Superset instance. */
|
||||
export const NORMALIZED_URL_FIELDS = new Set<string>(['explore_url']);
|
||||
|
||||
/**
|
||||
* URL-shaped fields that look normalisable but are deliberately left alone
|
||||
* (external destinations, CDN hosts, OAuth endpoints, deployment-dependent
|
||||
* targets). Informational only — keep in sync with the negative tests.
|
||||
*/
|
||||
export const NORMALIZER_EXCLUSIONS: ReadonlyArray<{
|
||||
field: string;
|
||||
reason: string;
|
||||
}> = [
|
||||
{ field: 'bug_report_url', reason: 'External (GitHub)' },
|
||||
{ field: 'documentation_url', reason: 'External (docs site)' },
|
||||
{ field: 'external_url', reason: 'External by name' },
|
||||
{ field: 'bundle_url', reason: 'CDN / static asset host' },
|
||||
{ field: 'tracking_url', reason: 'External (analytics)' },
|
||||
{ field: 'user_login_url', reason: 'OAuth / SSO endpoint, may be external' },
|
||||
{ field: 'user_logout_url', reason: 'OAuth / SSO endpoint, may be external' },
|
||||
{ field: 'user_info_url', reason: 'OAuth / SSO endpoint, may be external' },
|
||||
{ field: 'thumbnail_url', reason: 'Storage host varies (S3 / local)' },
|
||||
{ field: 'creator_url', reason: 'User-profile destination varies' },
|
||||
];
|
||||
|
||||
export interface NormalizeOptions {
|
||||
/** Application root to strip. Empty string disables normalisation. */
|
||||
applicationRoot: string;
|
||||
}
|
||||
|
||||
const SAFE_ABSOLUTE_URL_RE = /^(?:https?|ftp|mailto|tel):/i;
|
||||
|
||||
function stripTrailingSlash(root: string): string {
|
||||
return root.endsWith('/') ? root.slice(0, -1) : root;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object') return false;
|
||||
if (Object.prototype.toString.call(value) !== '[object Object]') return false;
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
// Accept any prototype that is itself a plain object root — this is
|
||||
// cross-realm-safe (a Response.json() result in jsdom may have a different
|
||||
// `Object.prototype` instance than the test-side prototype, even though the
|
||||
// shape is identical). Reject class instances by requiring the prototype's
|
||||
// own prototype to be `null`.
|
||||
if (proto === null) return true;
|
||||
return Object.getPrototypeOf(proto) === null;
|
||||
}
|
||||
|
||||
/** Normalise a single URL string (used directly when walking is overkill). */
|
||||
export function normalizeBackendUrlString(
|
||||
value: string,
|
||||
options: NormalizeOptions,
|
||||
): string {
|
||||
const root = stripTrailingSlash(options.applicationRoot);
|
||||
if (!root) return value;
|
||||
if (SAFE_ABSOLUTE_URL_RE.test(value)) return value;
|
||||
if (value.startsWith('//')) return value;
|
||||
if (value === root) return '/';
|
||||
if (value.startsWith(`${root}/`)) {
|
||||
return value.slice(root.length);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursion depth ceiling for `walk()` (AF-6, 2026-06-01). Production
|
||||
* payloads rarely nest beyond ~10 levels (chart `form_data` → adhoc filter
|
||||
* expressions, dashboard `json_metadata` → native_filter_configuration →
|
||||
* targets). A self-referential or pathologically deep object — e.g. a
|
||||
* `json_metadata` blob authored by a buggy plugin — must not stack-overflow
|
||||
* the renderer or the response-parse worker. At the cap the walker stops
|
||||
* descending and returns the subtree unchanged.
|
||||
*/
|
||||
export const NORMALIZE_MAX_DEPTH = 100;
|
||||
|
||||
function walk(
|
||||
value: unknown,
|
||||
root: string,
|
||||
depth: number,
|
||||
visited: WeakSet<object>,
|
||||
): unknown {
|
||||
if (depth >= NORMALIZE_MAX_DEPTH) return value;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (visited.has(value)) return value;
|
||||
visited.add(value);
|
||||
let changed = false;
|
||||
const out: unknown[] = [];
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const item = value[index];
|
||||
const next = walk(item, root, depth + 1, visited);
|
||||
if (next !== item) changed = true;
|
||||
out.push(next);
|
||||
}
|
||||
return changed ? out : value;
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
if (visited.has(value)) return value;
|
||||
visited.add(value);
|
||||
let changed = false;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value)) {
|
||||
const fieldValue = value[key];
|
||||
const nextValue =
|
||||
NORMALIZED_URL_FIELDS.has(key) && typeof fieldValue === 'string'
|
||||
? normalizeBackendUrlString(fieldValue, { applicationRoot: root })
|
||||
: walk(fieldValue, root, depth + 1, visited);
|
||||
if (nextValue !== fieldValue) changed = true;
|
||||
out[key] = nextValue;
|
||||
}
|
||||
return changed ? out : value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively normalise URL fields in a JSON-shaped value. Returns the input
|
||||
* by reference when nothing changed, so callers can compare with `===`.
|
||||
*/
|
||||
export function normalizeBackendUrls<T>(
|
||||
value: T,
|
||||
options: NormalizeOptions,
|
||||
): T {
|
||||
const root = stripTrailingSlash(options.applicationRoot);
|
||||
if (!root) return value;
|
||||
return walk(value, root, 0, new WeakSet<object>()) as T;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export default function getDatasourceMetadata({
|
||||
}: Params) {
|
||||
return client
|
||||
.get({
|
||||
endpoint: `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`,
|
||||
endpoint: `/fetch_datasource_metadata?datasourceKey=${datasourceKey}`,
|
||||
...requestConfig,
|
||||
})
|
||||
.then(response => response.json as Datasource);
|
||||
|
||||
@@ -176,7 +176,9 @@ describe('ChartClient', () => {
|
||||
Promise.reject(new Error('Unexpected all to v1 API')),
|
||||
);
|
||||
|
||||
fetchMock.post('glob:*/superset/explore_json/', {
|
||||
// Slice 3c: post `Superset.route_base = ""`, the legacy endpoint
|
||||
// collapsed from `/superset/explore_json/` to `/explore_json/`.
|
||||
fetchMock.post('glob:*/explore_json/', {
|
||||
field1: 'abc',
|
||||
field2: 'def',
|
||||
});
|
||||
@@ -198,13 +200,10 @@ describe('ChartClient', () => {
|
||||
|
||||
describe('.loadDatasource(datasourceKey, options)', () => {
|
||||
test('fetches datasource', () => {
|
||||
fetchMock.get(
|
||||
'glob:*/superset/fetch_datasource_metadata?datasourceKey=1__table',
|
||||
{
|
||||
field1: 'abc',
|
||||
field2: 'def',
|
||||
},
|
||||
);
|
||||
fetchMock.get('glob:*/fetch_datasource_metadata?datasourceKey=1__table', {
|
||||
field1: 'abc',
|
||||
field2: 'def',
|
||||
});
|
||||
|
||||
return expect(chartClient.loadDatasource('1__table')).resolves.toEqual({
|
||||
field1: 'abc',
|
||||
@@ -264,13 +263,10 @@ describe('ChartClient', () => {
|
||||
color: 'living-coral',
|
||||
});
|
||||
|
||||
fetchMock.get(
|
||||
'glob:*/superset/fetch_datasource_metadata?datasourceKey=1__table',
|
||||
{
|
||||
name: 'transactions',
|
||||
schema: 'staging',
|
||||
},
|
||||
);
|
||||
fetchMock.get('glob:*/fetch_datasource_metadata?datasourceKey=1__table', {
|
||||
name: 'transactions',
|
||||
schema: 'staging',
|
||||
});
|
||||
|
||||
fetchMock.post('glob:*/api/v1/chart/data', {
|
||||
lorem: 'ipsum',
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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 { SupersetClientClass } from '@superset-ui/core';
|
||||
|
||||
// SupersetClient is expected to apply the configured appRoot exactly once.
|
||||
// Callers must pass router-relative endpoints; pre-prefixing causes the
|
||||
// double-prefix bug documented below.
|
||||
|
||||
describe('SupersetClient applies the application root exactly once', () => {
|
||||
const buildClient = () =>
|
||||
new SupersetClientClass({
|
||||
protocol: 'https:',
|
||||
host: 'config_host',
|
||||
appRoot: '/superset',
|
||||
});
|
||||
|
||||
test('endpoint without leading slash is concatenated correctly', () => {
|
||||
expect(buildClient().getUrl({ endpoint: 'api/v1/chart' })).toBe(
|
||||
'https://config_host/superset/api/v1/chart',
|
||||
);
|
||||
});
|
||||
|
||||
test('endpoint with leading slash is normalised to a single root segment', () => {
|
||||
expect(buildClient().getUrl({ endpoint: '/api/v1/chart' })).toBe(
|
||||
'https://config_host/superset/api/v1/chart',
|
||||
);
|
||||
});
|
||||
|
||||
// Runtime safety net: if a caller pre-prefixes the endpoint (e.g. by wrapping
|
||||
// with ensureAppRoot before calling), getUrl strips the duplicate. The L2
|
||||
// static invariant still flags the pattern at the call site — this guards
|
||||
// against the bug reaching production if the static check is bypassed.
|
||||
test('dedupes a leading application-root segment from a pre-prefixed endpoint', () => {
|
||||
expect(buildClient().getUrl({ endpoint: '/superset/api/v1/chart' })).toBe(
|
||||
'https://config_host/superset/api/v1/chart',
|
||||
);
|
||||
});
|
||||
|
||||
// AF-5 reconciliation (2026-06-01): single-pass strip preserves a
|
||||
// legitimate `/superset/superset/<slug>` route. Under the Slice-7
|
||||
// invariant the inbound normaliser at `request()` strips any double
|
||||
// prefix in backend payloads before it reaches `getUrl`, so a doubled
|
||||
// leading segment that reaches this point is a real route, not a bug.
|
||||
// This pin guards against silent regression to the prior greedy strip.
|
||||
test('strips exactly one application-root segment (single-pass)', () => {
|
||||
expect(
|
||||
buildClient().getUrl({ endpoint: '/superset/superset/api/v1/chart' }),
|
||||
).toBe('https://config_host/superset/superset/api/v1/chart');
|
||||
expect(
|
||||
buildClient().getUrl({
|
||||
endpoint: '/superset/superset/superset/api/v1/chart',
|
||||
}),
|
||||
).toBe('https://config_host/superset/superset/superset/api/v1/chart');
|
||||
});
|
||||
|
||||
test('dedupe is segment-boundary aware — `/supersetfoo` is not a prefix match', () => {
|
||||
expect(buildClient().getUrl({ endpoint: '/supersetfoo/x' })).toBe(
|
||||
'https://config_host/superset/supersetfoo/x',
|
||||
);
|
||||
});
|
||||
|
||||
test('dedupes the bare application root to an empty endpoint', () => {
|
||||
expect(buildClient().getUrl({ endpoint: '/superset' })).toBe(
|
||||
'https://config_host/superset/',
|
||||
);
|
||||
});
|
||||
|
||||
test('empty application root produces no prefix segment', () => {
|
||||
const client = new SupersetClientClass({
|
||||
protocol: 'https:',
|
||||
host: 'config_host',
|
||||
});
|
||||
expect(client.getUrl({ endpoint: '/api/v1/chart' })).toBe(
|
||||
'https://config_host/api/v1/chart',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Slice 7 live-wire assertions for `normalizeBackendUrls` (PR #39925).
|
||||
*
|
||||
* The contract under test is the inbound seam at `SupersetClientClass.request()`
|
||||
* threading `this.appRoot` through `callApiAndParseWithTimeout` → `parseResponse`
|
||||
* → `normalizeBackendUrls`. The earlier `normalizeBackendUrls.test.ts` proves
|
||||
* the pure function in isolation; this file proves the *plumbing* — kills the
|
||||
* false-assurance gap that a pure-function suite creates while the module is
|
||||
* unwired.
|
||||
*
|
||||
* Coverage:
|
||||
* - `json` path: a recognised URL field is stripped end-to-end.
|
||||
* - `json-bigint` path: a recognised URL field is stripped *and* a sibling
|
||||
* BigInt-typed value in the same object survives un-mutated (regression
|
||||
* against the `cloneDeepWith` customizer × `walk()` interleave).
|
||||
* - Empty `appRoot` is an explicit no-op (Layer-2 invariant: the normaliser
|
||||
* should be inert when no subdirectory is configured).
|
||||
*/
|
||||
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { SupersetClient, SupersetClientClass } from '@superset-ui/core';
|
||||
import { LOGIN_GLOB } from './fixtures/constants';
|
||||
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
|
||||
describe('SupersetClient inbound normaliser plumbing (Slice 7)', () => {
|
||||
beforeAll(() => {
|
||||
fetchMock.get(LOGIN_GLOB, { result: '1234' });
|
||||
});
|
||||
|
||||
afterAll(() => fetchMock.removeRoutes().clearHistory());
|
||||
|
||||
afterEach(() => {
|
||||
SupersetClient.reset();
|
||||
fetchMock.removeRoutes().clearHistory();
|
||||
});
|
||||
|
||||
test('strips appRoot from a recognised URL field on the json path', async () => {
|
||||
const chartsUrl = 'https://host/superset/api/v1/chart/';
|
||||
fetchMock.get(chartsUrl, {
|
||||
result: [
|
||||
{ id: 1, explore_url: '/superset/explore/?slice_id=1' },
|
||||
{ id: 2, explore_url: '/superset/explore/?slice_id=2' },
|
||||
],
|
||||
});
|
||||
|
||||
SupersetClient.configure({
|
||||
protocol: 'https:',
|
||||
host: 'host',
|
||||
appRoot: '/superset',
|
||||
csrfToken: 'csrf',
|
||||
});
|
||||
await SupersetClient.init();
|
||||
|
||||
const response = await SupersetClient.get<'json'>({
|
||||
endpoint: '/api/v1/chart/',
|
||||
});
|
||||
const payload = response.json as {
|
||||
result: Array<{ id: number; explore_url: string }>;
|
||||
};
|
||||
|
||||
expect(payload.result[0].explore_url).toBe('/explore/?slice_id=1');
|
||||
expect(payload.result[1].explore_url).toBe('/explore/?slice_id=2');
|
||||
});
|
||||
|
||||
test('does not strip when appRoot is empty (inert under root deployment)', async () => {
|
||||
const chartsUrl = 'https://host/api/v1/chart/';
|
||||
fetchMock.get(chartsUrl, {
|
||||
result: [{ id: 1, explore_url: '/explore/?slice_id=1' }],
|
||||
});
|
||||
|
||||
SupersetClient.configure({
|
||||
protocol: 'https:',
|
||||
host: 'host',
|
||||
// appRoot omitted → DEFAULT_APP_ROOT ('')
|
||||
csrfToken: 'csrf',
|
||||
});
|
||||
await SupersetClient.init();
|
||||
|
||||
const response = await SupersetClient.get<'json'>({
|
||||
endpoint: '/api/v1/chart/',
|
||||
});
|
||||
const payload = response.json as {
|
||||
result: Array<{ explore_url: string }>;
|
||||
};
|
||||
expect(payload.result[0].explore_url).toBe('/explore/?slice_id=1');
|
||||
});
|
||||
|
||||
test('json-bigint: strips URL field and preserves a sibling BigInt un-mutated', async () => {
|
||||
// A large integer (> Number.MAX_SAFE_INTEGER) becomes a BigInt under the
|
||||
// `json-bigint` path's `cloneDeepWith` customizer. A sibling URL string at
|
||||
// a recognised field must be normalised *and* the BigInt must remain
|
||||
// identical — proving the `cloneDeepWith` × normaliser interleave is safe.
|
||||
const bigInt = '9223372036854775807'; // 2^63 - 1, far beyond Number.MAX_SAFE_INTEGER
|
||||
const url = 'https://host/superset/api/v1/chart/payload';
|
||||
const raw = `{ "id": ${bigInt}, "explore_url": "/superset/explore/?slice_id=42" }`;
|
||||
fetchMock.get(url, raw);
|
||||
|
||||
SupersetClient.configure({
|
||||
protocol: 'https:',
|
||||
host: 'host',
|
||||
appRoot: '/superset',
|
||||
csrfToken: 'csrf',
|
||||
});
|
||||
await SupersetClient.init();
|
||||
|
||||
const response = await SupersetClient.get<'json-bigint'>({
|
||||
endpoint: '/api/v1/chart/payload',
|
||||
parseMethod: 'json-bigint',
|
||||
});
|
||||
const payload = response.json as { id: bigint; explore_url: string };
|
||||
|
||||
expect(payload.explore_url).toBe('/explore/?slice_id=42');
|
||||
expect(typeof payload.id).toBe('bigint');
|
||||
expect(payload.id).toBe(BigInt(bigInt));
|
||||
expect(payload.id.toString()).toBe(bigInt);
|
||||
});
|
||||
|
||||
test('plumbs the appRoot param through request → callApiAndParseWithTimeout → parseResponse', async () => {
|
||||
// End-to-end seam assertion: prove that swapping `this.appRoot` on the
|
||||
// class instance changes the normalisation behaviour observed at the
|
||||
// response, which can only happen if the value flows all the way to
|
||||
// `parseResponse`. A `parseResponse`-in-isolation test would miss the
|
||||
// wiring regression this guards against.
|
||||
const url = 'https://host/preset/superset/api/v1/chart/wired';
|
||||
fetchMock.get(url, {
|
||||
explore_url: '/preset/superset/explore/?slice_id=1',
|
||||
});
|
||||
|
||||
const client = new SupersetClientClass({
|
||||
protocol: 'https:',
|
||||
host: 'host',
|
||||
appRoot: '/preset/superset',
|
||||
csrfToken: 'csrf',
|
||||
});
|
||||
await client.init();
|
||||
|
||||
const response = await client.get<'json'>({
|
||||
endpoint: '/api/v1/chart/wired',
|
||||
});
|
||||
const payload = response.json as { explore_url: string };
|
||||
expect(payload.explore_url).toBe('/explore/?slice_id=1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 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 {
|
||||
normalizeBackendUrls,
|
||||
NORMALIZED_URL_FIELDS,
|
||||
NORMALIZER_EXCLUSIONS,
|
||||
} from '../../src/connection/normalizeBackendUrls';
|
||||
|
||||
const PREFIX = '/superset';
|
||||
|
||||
// Contract test (P0-1, surfaced by the 2026-06-02 subdirectory test-gap audit).
|
||||
//
|
||||
// The runtime normaliser uses a closed allow-list. Adding a new backend
|
||||
// `*_url` field that the allow-list doesn't know about is a silent failure
|
||||
// mode — under `APPLICATION_ROOT=/superset` the frontend re-prefixes the
|
||||
// field and produces `/superset/superset/...`. The bug only surfaces in
|
||||
// production, manually, when someone reports a doubled URL.
|
||||
//
|
||||
// These tests pin two contracts that catch the failure mode in CI:
|
||||
// 1. The allow-list and the exclusion ledger are exhaustive for every
|
||||
// backend `*_url` field the frontend can encounter today.
|
||||
// 2. The allow-list and the exclusion ledger are disjoint (a field is
|
||||
// either normalised or explicitly exempt, never both).
|
||||
//
|
||||
// When a backend schema adds a new `*_url` field, the contributor must
|
||||
// classify it here — normalise (add to NORMALIZED_URL_FIELDS) or exempt
|
||||
// (add to NORMALIZER_EXCLUSIONS with a reason). Both branches force a
|
||||
// conscious decision rather than the silent default.
|
||||
|
||||
/**
|
||||
* Known backend `*_url` fields that reach the frontend payload.
|
||||
*
|
||||
* Sources (grepped 2026-06-02 against `master` + subdir branch):
|
||||
* - `superset/charts/schemas.py` — slice_url, chart_url, thumbnail_url
|
||||
* - `superset/dashboards/schemas.py` — dashboard_url, image_url, edit_url, thumbnail_url, url
|
||||
* - `superset/datasource/schemas.py` — explore_url
|
||||
* - `superset/databases/schemas.py` — none today
|
||||
* - `superset/models/slice.py.data` — slice_url, edit_url
|
||||
* - `superset/models/dashboard.py.data` — url (router-relative)
|
||||
* - bootstrap payload — bug_report_url, documentation_url
|
||||
*
|
||||
* Convention: backend properties that build paths inline (`f"/explore/..."`)
|
||||
* emit router-relative paths and frontend `ensureAppRoot` is correct as-is.
|
||||
* Backend callers that use `get_url_path()` emit fully-qualified absolute
|
||||
* URLs (skipped by SAFE_ABSOLUTE_URL_RE). The single field that historically
|
||||
* emits a prefixed router-relative path is `explore_url` (the
|
||||
* `default_endpoint` branch in `connectors/sqla/models.py:402` can hold any
|
||||
* operator-saved string, commonly the prefixed `/superset/explore/...`).
|
||||
*/
|
||||
const KNOWN_BACKEND_URL_FIELDS: ReadonlyArray<string> = [
|
||||
// Allow-list members (must be normalised; backend may emit prefixed):
|
||||
'explore_url',
|
||||
// Exclusion-list members (intentionally NOT normalised — see EXCLUSIONS reasons):
|
||||
'bug_report_url',
|
||||
'documentation_url',
|
||||
'external_url',
|
||||
'bundle_url',
|
||||
'tracking_url',
|
||||
'user_login_url',
|
||||
'user_logout_url',
|
||||
'user_info_url',
|
||||
'thumbnail_url',
|
||||
'creator_url',
|
||||
];
|
||||
|
||||
test('NORMALIZED_URL_FIELDS contains exactly the documented allow-list', () => {
|
||||
// Snapshot of the allow-list. Changing this is a deliberate contract
|
||||
// change — update KNOWN_BACKEND_URL_FIELDS above + grep evidence to keep
|
||||
// the audit fresh.
|
||||
expect([...NORMALIZED_URL_FIELDS].sort()).toEqual(['explore_url']);
|
||||
});
|
||||
|
||||
test('NORMALIZER_EXCLUSIONS contains exactly the documented exclusions', () => {
|
||||
// Snapshot of the exclusion ledger. Each entry has a `reason` so a future
|
||||
// maintainer can decide whether the rationale still holds when the
|
||||
// backend convention changes.
|
||||
expect(NORMALIZER_EXCLUSIONS.map(({ field }) => field).sort()).toEqual(
|
||||
[
|
||||
'bug_report_url',
|
||||
'bundle_url',
|
||||
'creator_url',
|
||||
'documentation_url',
|
||||
'external_url',
|
||||
'thumbnail_url',
|
||||
'tracking_url',
|
||||
'user_info_url',
|
||||
'user_login_url',
|
||||
'user_logout_url',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test('every known backend *_url field is either normalised or exempt', () => {
|
||||
const allowList = new Set(NORMALIZED_URL_FIELDS);
|
||||
const exclusionList = new Set(
|
||||
NORMALIZER_EXCLUSIONS.map(({ field }) => field),
|
||||
);
|
||||
const unclassified = KNOWN_BACKEND_URL_FIELDS.filter(
|
||||
field => !allowList.has(field) && !exclusionList.has(field),
|
||||
);
|
||||
expect(unclassified).toEqual([]);
|
||||
});
|
||||
|
||||
test('allow-list and exclusion ledger are disjoint', () => {
|
||||
const exclusionList = NORMALIZER_EXCLUSIONS.map(({ field }) => field);
|
||||
const overlap = exclusionList.filter(field =>
|
||||
NORMALIZED_URL_FIELDS.has(field),
|
||||
);
|
||||
expect(overlap).toEqual([]);
|
||||
});
|
||||
|
||||
test('NORMALIZER_EXCLUSIONS entries each carry a non-empty reason', () => {
|
||||
// The `reason` column is the only context a future contributor has when
|
||||
// deciding whether to graduate an exclusion to the allow-list. An empty
|
||||
// reason is worse than no entry — it implies a decision was made when in
|
||||
// fact none was recorded.
|
||||
for (const { field, reason } of NORMALIZER_EXCLUSIONS) {
|
||||
expect(reason).toBeTruthy();
|
||||
expect(reason.length).toBeGreaterThan(3);
|
||||
expect(typeof field).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
// Behavioural assertions per known field — these are the runtime contracts
|
||||
// the allow-list + exclusion classification translates into. If a future
|
||||
// refactor moves a field between buckets without updating the runtime, this
|
||||
// is where the symptom surfaces.
|
||||
|
||||
test('allow-listed fields with prefixed values are stripped to router-relative', () => {
|
||||
const input = {
|
||||
explore_url: '/superset/explore/?datasource_type=table&datasource_id=1',
|
||||
};
|
||||
expect(normalizeBackendUrls(input, { applicationRoot: PREFIX })).toEqual({
|
||||
explore_url: '/explore/?datasource_type=table&datasource_id=1',
|
||||
});
|
||||
});
|
||||
|
||||
test('exempt fields are passed through even when prefixed-looking', () => {
|
||||
// `thumbnail_url` and friends may legitimately be `/superset/...` (when the
|
||||
// storage host happens to share the deployment origin) or fully external
|
||||
// (S3). The normaliser leaves them alone in both cases — frontend treats
|
||||
// the value as opaque.
|
||||
const input = {
|
||||
thumbnail_url: '/superset/thumbnail/abc',
|
||||
bug_report_url: 'https://github.com/apache/superset/issues',
|
||||
user_logout_url: '/superset/logout/',
|
||||
};
|
||||
expect(normalizeBackendUrls(input, { applicationRoot: PREFIX })).toEqual(
|
||||
input,
|
||||
);
|
||||
});
|
||||
|
||||
test('router-relative non-prefixed values on allow-listed fields are untouched', () => {
|
||||
// `slice_url` / `edit_url` etc. are router-relative by backend convention
|
||||
// (no `/superset` prefix). When the value doesn't start with the root,
|
||||
// the normaliser passes it through. This pin protects against a future
|
||||
// change that would aggressively strip a leading slash or rewrite a
|
||||
// non-matching value.
|
||||
const input = { explore_url: '/explore/?slice_id=42' };
|
||||
expect(normalizeBackendUrls(input, { applicationRoot: PREFIX })).toEqual(
|
||||
input,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 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 {
|
||||
normalizeBackendUrls,
|
||||
normalizeBackendUrlString,
|
||||
NORMALIZED_URL_FIELDS,
|
||||
NORMALIZE_MAX_DEPTH,
|
||||
} from '../../src/connection/normalizeBackendUrls';
|
||||
|
||||
const PREFIX = '/superset';
|
||||
|
||||
describe('normalizeBackendUrls', () => {
|
||||
test('strips application root from a recognised URL field', () => {
|
||||
const input = { id: 1, explore_url: '/superset/explore/?slice_id=1' };
|
||||
const output = normalizeBackendUrls(input, { applicationRoot: PREFIX });
|
||||
expect(output).toEqual({ id: 1, explore_url: '/explore/?slice_id=1' });
|
||||
});
|
||||
|
||||
// The negative cases below prove the normaliser is conservative: it doesn't
|
||||
// mutate user content, external URLs, or path segments that merely share
|
||||
// text with the configured root.
|
||||
test('leaves non-allow-listed fields untouched even when path-shaped', () => {
|
||||
const input = { description: '/superset/just-text-from-a-user' };
|
||||
expect(normalizeBackendUrls(input, { applicationRoot: PREFIX })).toEqual(
|
||||
input,
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves absolute URLs untouched in recognised fields', () => {
|
||||
const input = { explore_url: 'https://other.example.com/superset/foo' };
|
||||
expect(normalizeBackendUrls(input, { applicationRoot: PREFIX })).toEqual(
|
||||
input,
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves protocol-relative URLs untouched', () => {
|
||||
const input = { explore_url: '//cdn.example.com/superset/foo' };
|
||||
expect(normalizeBackendUrls(input, { applicationRoot: PREFIX })).toEqual(
|
||||
input,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not strip a similar-but-different prefix segment', () => {
|
||||
// /superset-public/... shares text with /superset but is a different path
|
||||
// segment. Only /superset followed by / or end-of-string counts.
|
||||
const input = { explore_url: '/superset-public/explore/?slice_id=1' };
|
||||
expect(normalizeBackendUrls(input, { applicationRoot: PREFIX })).toEqual(
|
||||
input,
|
||||
);
|
||||
});
|
||||
|
||||
test('is a no-op when application root is empty', () => {
|
||||
const input = { explore_url: '/explore/?slice_id=1' };
|
||||
expect(normalizeBackendUrls(input, { applicationRoot: '' })).toEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeBackendUrlString', () => {
|
||||
test('strips application root from a router-relative path', () => {
|
||||
expect(
|
||||
normalizeBackendUrlString('/superset/sqllab', {
|
||||
applicationRoot: PREFIX,
|
||||
}),
|
||||
).toBe('/sqllab');
|
||||
});
|
||||
|
||||
test('passes absolute URLs through unchanged', () => {
|
||||
expect(
|
||||
normalizeBackendUrlString('https://external.example.com/foo', {
|
||||
applicationRoot: PREFIX,
|
||||
}),
|
||||
).toBe('https://external.example.com/foo');
|
||||
});
|
||||
});
|
||||
|
||||
test('NORMALIZED_URL_FIELDS is a Set for O(1) lookup', () => {
|
||||
expect(NORMALIZED_URL_FIELDS).toBeInstanceOf(Set);
|
||||
});
|
||||
|
||||
describe('normalizeBackendUrls (recursion + identity)', () => {
|
||||
test('descends into arrays and normalises matching fields per element', () => {
|
||||
const input = [
|
||||
{ explore_url: '/superset/explore/?id=1' },
|
||||
{ explore_url: '/superset/explore/?id=2' },
|
||||
];
|
||||
expect(normalizeBackendUrls(input, { applicationRoot: PREFIX })).toEqual([
|
||||
{ explore_url: '/explore/?id=1' },
|
||||
{ explore_url: '/explore/?id=2' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('descends into nested objects', () => {
|
||||
const input = {
|
||||
result: { chart: { explore_url: '/superset/explore/?id=1' } },
|
||||
};
|
||||
expect(normalizeBackendUrls(input, { applicationRoot: PREFIX })).toEqual({
|
||||
result: { chart: { explore_url: '/explore/?id=1' } },
|
||||
});
|
||||
});
|
||||
|
||||
test('returns input by reference when nothing changed', () => {
|
||||
const input = { explore_url: '/explore/?id=1' };
|
||||
const output = normalizeBackendUrls(input, { applicationRoot: PREFIX });
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
|
||||
test('is idempotent: normalize(normalize(x)) === normalize(x)', () => {
|
||||
const input = { explore_url: '/superset/explore/?id=1' };
|
||||
const once = normalizeBackendUrls(input, { applicationRoot: PREFIX });
|
||||
const twice = normalizeBackendUrls(once, { applicationRoot: PREFIX });
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
|
||||
test('strips a value that equals the application root exactly', () => {
|
||||
expect(
|
||||
normalizeBackendUrlString('/superset', { applicationRoot: PREFIX }),
|
||||
).toBe('/');
|
||||
});
|
||||
|
||||
test('tolerates a trailing slash on applicationRoot', () => {
|
||||
expect(
|
||||
normalizeBackendUrlString('/superset/foo', {
|
||||
applicationRoot: '/superset/',
|
||||
}),
|
||||
).toBe('/foo');
|
||||
});
|
||||
|
||||
test('does not descend into class instances (Date, Map)', () => {
|
||||
const date = new Date('2026-01-01');
|
||||
const input = { created_at: date };
|
||||
const output = normalizeBackendUrls(input, { applicationRoot: PREFIX });
|
||||
expect(output.created_at).toBe(date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeBackendUrls (AF-6 walk hardening)', () => {
|
||||
test('exports a finite recursion depth ceiling', () => {
|
||||
expect(typeof NORMALIZE_MAX_DEPTH).toBe('number');
|
||||
expect(NORMALIZE_MAX_DEPTH).toBeGreaterThan(0);
|
||||
expect(Number.isFinite(NORMALIZE_MAX_DEPTH)).toBe(true);
|
||||
});
|
||||
|
||||
test('terminates without stack-overflow on a self-referential object', () => {
|
||||
type Cyclic = { explore_url: string; self?: Cyclic };
|
||||
const cyclic: Cyclic = { explore_url: '/superset/explore/?id=1' };
|
||||
cyclic.self = cyclic;
|
||||
const output = normalizeBackendUrls(cyclic, { applicationRoot: PREFIX });
|
||||
expect(output.explore_url).toBe('/explore/?id=1');
|
||||
});
|
||||
|
||||
test('terminates without stack-overflow on a self-referential array', () => {
|
||||
const arr: unknown[] = [{ explore_url: '/superset/explore/?id=1' }];
|
||||
arr.push(arr);
|
||||
const output = normalizeBackendUrls(arr, { applicationRoot: PREFIX }) as [
|
||||
{ explore_url: string },
|
||||
unknown,
|
||||
];
|
||||
expect(output[0].explore_url).toBe('/explore/?id=1');
|
||||
});
|
||||
|
||||
test('stops descending past NORMALIZE_MAX_DEPTH and returns subtree unchanged', () => {
|
||||
type Nested = { explore_url?: string; child?: Nested };
|
||||
const buried: Nested = {
|
||||
explore_url: '/superset/explore/?id=deep',
|
||||
};
|
||||
let cursor: Nested = { child: buried };
|
||||
for (let i = 0; i < NORMALIZE_MAX_DEPTH + 5; i += 1) {
|
||||
cursor = { child: cursor };
|
||||
}
|
||||
const output = normalizeBackendUrls(cursor, { applicationRoot: PREFIX });
|
||||
// Walk into the structure following `child` pointers; once we pass the
|
||||
// depth ceiling, the deep `explore_url` must remain unstripped.
|
||||
let probe: Nested | undefined = output;
|
||||
while (probe?.explore_url === undefined && probe?.child !== undefined) {
|
||||
probe = probe.child;
|
||||
}
|
||||
expect(probe?.explore_url).toBe('/superset/explore/?id=deep');
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ test('format milliseconds in human readable format with default options', () =>
|
||||
});
|
||||
test('format seconds in human readable format with default options', () => {
|
||||
const formatter = createDurationFormatter({ multiplier: 1000 });
|
||||
expect(formatter(-0.5)).toBe('0s');
|
||||
expect(formatter(-0.5)).toBe('-0s');
|
||||
expect(formatter(0.5)).toBe('0s');
|
||||
expect(formatter(1)).toBe('1s');
|
||||
expect(formatter(30)).toBe('30s');
|
||||
|
||||
@@ -35,8 +35,10 @@ describe('getFormData()', () => {
|
||||
field2: 'def',
|
||||
};
|
||||
|
||||
// Slice 3c: post-`route_base=""`, the legacy endpoint collapsed
|
||||
// from `/superset/fetch_datasource_metadata` to `/fetch_datasource_metadata`.
|
||||
fetchMock.get(
|
||||
'glob:*/superset/fetch_datasource_metadata?datasourceKey=1__table',
|
||||
'glob:*/fetch_datasource_metadata?datasourceKey=1__table',
|
||||
mockData,
|
||||
);
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export class DashboardPage {
|
||||
* @param slug - The dashboard slug (e.g., 'world_health')
|
||||
*/
|
||||
async gotoBySlug(slug: string): Promise<void> {
|
||||
await gotoWithRetry(this.page, `superset/dashboard/${slug}/`);
|
||||
await gotoWithRetry(this.page, `dashboard/${slug}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +52,7 @@ export class DashboardPage {
|
||||
* @param id - The dashboard ID
|
||||
*/
|
||||
async gotoById(id: number): Promise<void> {
|
||||
await gotoWithRetry(this.page, `superset/dashboard/${id}/`);
|
||||
await gotoWithRetry(this.page, `dashboard/${id}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,5 +35,5 @@ export const URL = {
|
||||
LOGIN: 'login/',
|
||||
SAVED_QUERIES_LIST: 'savedqueryview/list/',
|
||||
SQLLAB: 'sqllab',
|
||||
WELCOME: 'superset/welcome/',
|
||||
WELCOME: 'welcome/',
|
||||
} as const;
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.18.1",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
"dompurify": "^3.4.8",
|
||||
"dompurify": "^3.4.7",
|
||||
"prop-types": "^15.8.1",
|
||||
"urijs": "^1.19.11"
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@deck.gl/extensions": "~9.2.9",
|
||||
"@deck.gl/geo-layers": "~9.2.5",
|
||||
"@deck.gl/layers": "~9.2.5",
|
||||
"@deck.gl/mapbox": "~9.3.3",
|
||||
"@deck.gl/mapbox": "~9.3.2",
|
||||
"@deck.gl/mesh-layers": "~9.2.5",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
|
||||
183
superset-frontend/spec/helpers/sourceTreeScanner.ts
Normal file
183
superset-frontend/spec/helpers/sourceTreeScanner.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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 { readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { join, relative, resolve, sep } from 'path';
|
||||
|
||||
const DEFAULT_ROOTS = ['src', 'packages/superset-ui-core/src'];
|
||||
|
||||
const ALWAYS_SKIP_SEGMENTS = new Set([
|
||||
'node_modules',
|
||||
'dist',
|
||||
'build',
|
||||
'coverage',
|
||||
'__mocks__',
|
||||
'cypress-base',
|
||||
'playwright',
|
||||
]);
|
||||
|
||||
const ALWAYS_SKIP_SUFFIXES = [
|
||||
'.test.ts',
|
||||
'.test.tsx',
|
||||
'.stories.ts',
|
||||
'.stories.tsx',
|
||||
];
|
||||
|
||||
const SOURCE_EXTENSIONS = ['.ts', '.tsx'];
|
||||
|
||||
export interface ScanOptions {
|
||||
/** Workspace-relative directories to scan. Defaults to the source tree. */
|
||||
roots?: string[];
|
||||
/** Extra path segments to skip on top of {@link ALWAYS_SKIP_SEGMENTS}. */
|
||||
ignoreSegments?: string[];
|
||||
/** Regex run against each line of each file. */
|
||||
pattern: RegExp;
|
||||
/** Workspace-relative paths (forward slashes) exempt from this scan. */
|
||||
allowlist?: string[];
|
||||
}
|
||||
|
||||
export interface ScanHit {
|
||||
/** Workspace-relative path with forward slashes. */
|
||||
file: string;
|
||||
/** 1-based line number. */
|
||||
line: number;
|
||||
/** The text of the matching line, trimmed. */
|
||||
text: string;
|
||||
/** The substring captured by `pattern`. */
|
||||
match: string;
|
||||
}
|
||||
|
||||
// __dirname resolves to <workspace>/spec/helpers regardless of cwd.
|
||||
const WORKSPACE_ROOT = resolve(__dirname, '..', '..');
|
||||
|
||||
function isSourceFile(name: string): boolean {
|
||||
return (
|
||||
SOURCE_EXTENSIONS.some(ext => name.endsWith(ext)) &&
|
||||
!ALWAYS_SKIP_SUFFIXES.some(suffix => name.endsWith(suffix))
|
||||
);
|
||||
}
|
||||
|
||||
function walk(directory: string, ignoreSegments: Set<string>): string[] {
|
||||
const found: string[] = [];
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
return found;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (ignoreSegments.has(entry.name)) continue;
|
||||
const absolute = join(directory, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
found.push(...walk(absolute, ignoreSegments));
|
||||
} else if (entry.isFile() && isSourceFile(entry.name)) {
|
||||
found.push(absolute);
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
function toForwardSlashes(path: string): string {
|
||||
return sep === '/' ? path : path.split(sep).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Line-by-line regex scan over the source tree. Returns one {@link ScanHit}
|
||||
* per matching line. Textual (not AST-based) — false positives on string
|
||||
* literals should be fixed by tightening the regex.
|
||||
*/
|
||||
export function scanSource(options: ScanOptions): ScanHit[] {
|
||||
const {
|
||||
roots = DEFAULT_ROOTS,
|
||||
ignoreSegments = [],
|
||||
pattern,
|
||||
allowlist = [],
|
||||
} = options;
|
||||
|
||||
const ignoreSet = new Set([...ALWAYS_SKIP_SEGMENTS, ...ignoreSegments]);
|
||||
const allowSet = new Set(allowlist);
|
||||
const hits: ScanHit[] = [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const root of roots) {
|
||||
const absoluteRoot = resolve(WORKSPACE_ROOT, root);
|
||||
let stat;
|
||||
try {
|
||||
stat = statSync(absoluteRoot);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!stat.isDirectory()) continue;
|
||||
|
||||
for (const absoluteFile of walk(absoluteRoot, ignoreSet)) {
|
||||
if (seen.has(absoluteFile)) continue;
|
||||
seen.add(absoluteFile);
|
||||
|
||||
const relativePath = toForwardSlashes(
|
||||
relative(WORKSPACE_ROOT, absoluteFile),
|
||||
);
|
||||
if (allowSet.has(relativePath)) continue;
|
||||
|
||||
const contents = readFileSync(absoluteFile, 'utf8');
|
||||
const lines = contents.split('\n');
|
||||
|
||||
// Reuse the regex per file. Without the `g` flag, `.exec` ignores
|
||||
// lastIndex, so recompiling per-line was wasted allocation.
|
||||
const lineRegex = pattern.flags.includes('g')
|
||||
? new RegExp(pattern.source, pattern.flags.replace('g', ''))
|
||||
: pattern;
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const lineText = lines[index];
|
||||
const match = lineRegex.exec(lineText);
|
||||
if (match) {
|
||||
hits.push({
|
||||
file: relativePath,
|
||||
line: index + 1,
|
||||
text: lineText.trim(),
|
||||
match: match[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** Format hits as a multi-line failure message: ` file:line — text`. */
|
||||
export function formatHits(hits: ScanHit[], header: string): string {
|
||||
if (hits.length === 0) return header;
|
||||
const lines = hits
|
||||
.slice(0, 50)
|
||||
.map(hit => ` ${hit.file}:${hit.line} — ${hit.text}`);
|
||||
const overflow =
|
||||
hits.length > 50 ? `\n ... and ${hits.length - 50} more` : '';
|
||||
return `${header}\n${lines.join('\n')}${overflow}`;
|
||||
}
|
||||
|
||||
/** Throw with a formatted message if `hits` is non-empty. */
|
||||
export function expectNoHits(hits: ScanHit[], header: string): void {
|
||||
if (hits.length > 0) {
|
||||
throw new Error(formatHits(hits, header));
|
||||
}
|
||||
}
|
||||
53
superset-frontend/spec/helpers/withApplicationRoot.ts
Normal file
53
superset-frontend/spec/helpers/withApplicationRoot.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Run `callback` with `getBootstrapData().common.application_root` set to
|
||||
* `applicationRoot`. Resets modules so any imports inside the callback see
|
||||
* the configured value, then restores the prior DOM and module cache on exit.
|
||||
* Pass `''` to simulate the default root-of-domain deployment.
|
||||
*/
|
||||
export async function withApplicationRoot<T>(
|
||||
applicationRoot: string,
|
||||
callback: () => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
const previousBody = document.body.innerHTML;
|
||||
|
||||
try {
|
||||
const bootstrapData = { common: { application_root: applicationRoot } };
|
||||
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify(bootstrapData)}'></div>`;
|
||||
jest.resetModules();
|
||||
await import('src/utils/getBootstrapData');
|
||||
return await callback();
|
||||
} finally {
|
||||
document.body.innerHTML = previousBody;
|
||||
jest.resetModules();
|
||||
}
|
||||
}
|
||||
|
||||
/** Run `body` once per scenario, each under a different application root. */
|
||||
export async function applicationRootScenarios<S extends { root: string }>(
|
||||
scenarios: S[],
|
||||
body: (scenario: S) => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
for (const scenario of scenarios) {
|
||||
// eslint-disable-next-line no-await-in-loop -- intentional: scenarios share document state.
|
||||
await withApplicationRoot(scenario.root, () => body(scenario));
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import type { SqlLabRootState } from 'src/SqlLab/types';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { useComponentDidUpdate } from '@superset-ui/core';
|
||||
import { Grid } from '@superset-ui/core/components';
|
||||
import { useViews } from 'src/core';
|
||||
import { views } from 'src/core';
|
||||
import { Splitter } from 'src/components/Splitter';
|
||||
import useEffectEvent from 'src/hooks/useEffectEvent';
|
||||
import useStoredSidebarWidth from 'src/components/ResizableSidebar/useStoredSidebarWidth';
|
||||
@@ -96,7 +96,7 @@ const AppLayout: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
|
||||
setRightWidth(possibleRightWidth);
|
||||
}
|
||||
};
|
||||
const viewItems = useViews(ViewLocations.sqllab.rightSidebar) || [];
|
||||
const viewItems = views.getViews(ViewLocations.sqllab.rightSidebar) || [];
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
import { fDuration, extendedDayjs } from '@superset-ui/core/utils/dates';
|
||||
import { SqlLabRootState } from 'src/SqlLab/types';
|
||||
import { UserWithPermissionsAndRoles as User } from 'src/types/bootstrapTypes';
|
||||
import { makeUrl } from 'src/utils/pathUtils';
|
||||
import { openInNewTab } from 'src/utils/navigationUtils';
|
||||
import ResultSet from '../ResultSet';
|
||||
import HighlightedSql from '../HighlightedSql';
|
||||
import { StaticPosition, StyledTooltip, ModalResultSetWrapper } from './styles';
|
||||
@@ -80,8 +80,7 @@ interface QueryTableProps {
|
||||
}
|
||||
|
||||
const openQuery = (id: number) => {
|
||||
const url = makeUrl(`/sqllab?queryId=${id}`);
|
||||
window.open(url);
|
||||
openInNewTab(`/sqllab?queryId=${id}`);
|
||||
};
|
||||
|
||||
const QueryTable = ({
|
||||
|
||||
@@ -53,7 +53,28 @@ jest.mock('@superset-ui/core', () => ({
|
||||
isFeatureEnabled: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
// Mock openInNewTab so the Create-chart "new window" branch can be asserted
|
||||
// without spawning a real window. The rest of navigationUtils stays real so
|
||||
// existing CSV-download tests keep using the genuine `redirect`/`makeUrl`.
|
||||
jest.mock('src/utils/navigationUtils', () => ({
|
||||
...jest.requireActual('src/utils/navigationUtils'),
|
||||
openInNewTab: jest.fn(),
|
||||
}));
|
||||
// eslint-disable-next-line import/order, import/first
|
||||
import { openInNewTab } from 'src/utils/navigationUtils';
|
||||
|
||||
// Stub postFormData so the Create-chart click resolves quickly; this lets
|
||||
// the test focus on the URL composition that happens after the resolve.
|
||||
jest.mock('src/explore/exploreUtils/formData', () => ({
|
||||
...jest.requireActual('src/explore/exploreUtils/formData'),
|
||||
postFormData: jest.fn(),
|
||||
}));
|
||||
// eslint-disable-next-line import/order, import/first
|
||||
import { postFormData } from 'src/explore/exploreUtils/formData';
|
||||
|
||||
const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
const mockOpenInNewTab = openInNewTab as jest.Mock;
|
||||
const mockPostFormData = postFormData as jest.Mock;
|
||||
|
||||
jest.mock('src/components/ErrorMessage', () => ({
|
||||
ErrorMessageWithStackTrace: () => <div data-test="error-message">Error</div>,
|
||||
@@ -160,6 +181,9 @@ describe('ResultSet', () => {
|
||||
beforeEach(() => {
|
||||
applicationRootMock.mockReturnValue('');
|
||||
mockStartExport.mockClear();
|
||||
mockOpenInNewTab.mockClear();
|
||||
mockPostFormData.mockReset();
|
||||
mockPostFormData.mockResolvedValue('test-form-data-key');
|
||||
});
|
||||
|
||||
// Add cleanup after each test
|
||||
@@ -1009,4 +1033,103 @@ describe('ResultSet', () => {
|
||||
screen.getByRole('button', { name: 'Results Action' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Create chart in new window opens single-prefixed explore URL under subdirectory deployment', async () => {
|
||||
// When the user metaKey-clicks "Create chart", the SQL-Lab result handoff
|
||||
// composes an explore URL via mountExploreUrl(..., includeAppRoot=true).
|
||||
// Under SUPERSET_APP_ROOT=/superset, the resulting URL must contain the
|
||||
// prefix exactly once. A doubled prefix (/superset/superset/explore/…)
|
||||
// produces a blank Explore page.
|
||||
const appRoot = '/superset';
|
||||
applicationRootMock.mockReturnValue(appRoot);
|
||||
|
||||
const queryWithId = {
|
||||
...queries[0],
|
||||
results: {
|
||||
...queries[0].results,
|
||||
query_id: 42,
|
||||
},
|
||||
};
|
||||
|
||||
const { getByTestId } = setup(
|
||||
{
|
||||
...mockedProps,
|
||||
queryId: queryWithId.id,
|
||||
database: { allows_subquery: true, allows_virtual_table_explore: true },
|
||||
},
|
||||
mockStore({
|
||||
...initialState,
|
||||
user,
|
||||
sqlLab: {
|
||||
...initialState.sqlLab,
|
||||
queries: {
|
||||
[queryWithId.id]: queryWithId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const exploreButton = await waitFor(() =>
|
||||
getByTestId('explore-results-button'),
|
||||
);
|
||||
fireEvent.click(exploreButton, { metaKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenInNewTab).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const url = mockOpenInNewTab.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/^\/superset\/explore\/\?.*form_data_key=/);
|
||||
expect(url).not.toMatch(/\/superset\/superset\//);
|
||||
});
|
||||
|
||||
test('Create chart in same window pushes router-relative explore URL under subdirectory deployment', async () => {
|
||||
// Same-tab click (no metaKey) goes through history.push under the SPA
|
||||
// basename Router, so mountExploreUrl is called with includeAppRoot=false.
|
||||
// The composed URL must NOT carry an app-root prefix — the router applies
|
||||
// it once via <Router basename={applicationRoot()}>. A premature prefix
|
||||
// here would compound with the basename and yield /superset/superset/…
|
||||
const appRoot = '/superset';
|
||||
applicationRootMock.mockReturnValue(appRoot);
|
||||
|
||||
const queryWithId = {
|
||||
...queries[0],
|
||||
results: {
|
||||
...queries[0].results,
|
||||
query_id: 99,
|
||||
},
|
||||
};
|
||||
|
||||
const store = mockStore({
|
||||
...initialState,
|
||||
user,
|
||||
sqlLab: {
|
||||
...initialState.sqlLab,
|
||||
queries: {
|
||||
[queryWithId.id]: queryWithId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { getByTestId } = render(
|
||||
<ResultSet
|
||||
{...mockedProps}
|
||||
queryId={queryWithId.id}
|
||||
database={{
|
||||
allows_subquery: true,
|
||||
allows_virtual_table_explore: true,
|
||||
}}
|
||||
/>,
|
||||
{ useRedux: true, store, useRouter: true },
|
||||
);
|
||||
|
||||
const exploreButton = await waitFor(() =>
|
||||
getByTestId('explore-results-button'),
|
||||
);
|
||||
fireEvent.click(exploreButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPostFormData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(mockOpenInNewTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { sanitizeUrl } from '@braintree/sanitize-url';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -88,7 +87,7 @@ import { usePermissions } from 'src/hooks/usePermissions';
|
||||
import { StreamingExportModal } from 'src/components/StreamingExportModal';
|
||||
import { useStreamingExport } from 'src/components/StreamingExportModal/useStreamingExport';
|
||||
import { useConfirmModal } from 'src/hooks/useConfirmModal';
|
||||
import { makeUrl } from 'src/utils/pathUtils';
|
||||
import { makeUrl, openInNewTab, redirect } from 'src/utils/navigationUtils';
|
||||
import ExploreCtasResultsButton from '../ExploreCtasResultsButton';
|
||||
import ExploreResultsButton from '../ExploreResultsButton';
|
||||
import HighlightedSql from '../HighlightedSql';
|
||||
@@ -312,7 +311,9 @@ const ResultSet = ({
|
||||
includeAppRoot,
|
||||
);
|
||||
if (openInNewWindow) {
|
||||
window.open(url, '_blank', 'noreferrer');
|
||||
// `url` is from `mountExploreUrl(..., includeAppRoot=true)`; the
|
||||
// helper re-applies `ensureAppRoot` idempotently.
|
||||
openInNewTab(url);
|
||||
} else {
|
||||
history.push(url);
|
||||
}
|
||||
@@ -379,7 +380,13 @@ const ResultSet = ({
|
||||
{ rows: rowsCount.toLocaleString() },
|
||||
),
|
||||
onConfirm: () => {
|
||||
window.location.href = sanitizeUrl(getExportCsvUrl(query.id));
|
||||
// `getExportCsvUrl` already runs the path through `makeUrl`;
|
||||
// `redirect` re-applies `ensureAppRoot` idempotently and routes
|
||||
// the sink through navigationUtils' barriers (scheme allowlist,
|
||||
// userinfo rejection, AF-1 backslash rejection), which is a
|
||||
// strict superset of what `sanitizeUrl` from master PR #40546
|
||||
// provides.
|
||||
redirect(getExportCsvUrl(query.id));
|
||||
},
|
||||
confirmText: t('OK'),
|
||||
cancelText: t('Close'),
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { sanitizeUrl } from '@braintree/sanitize-url';
|
||||
import { useCallback, useState, FormEvent } from 'react';
|
||||
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
|
||||
import { Radio, RadioChangeEvent } from '@superset-ui/core/components/Radio';
|
||||
@@ -58,6 +57,7 @@ import { postFormData } from 'src/explore/exploreUtils/formData';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { clearDatasetCache } from 'src/utils/cachedSupersetGet';
|
||||
import { openInNewTab, redirect } from 'src/utils/navigationUtils';
|
||||
|
||||
interface QueryDatabase {
|
||||
id?: number;
|
||||
@@ -244,10 +244,16 @@ export const SaveDatasetModal = ({
|
||||
useState(false);
|
||||
|
||||
const createWindow = (url: string) => {
|
||||
// `url` is from `mountExploreUrl(..., includeAppRoot=true)`; the
|
||||
// navigationUtils helpers re-apply `ensureAppRoot` idempotently.
|
||||
if (openWindow) {
|
||||
window.open(sanitizeUrl(url), '_blank', 'noreferrer');
|
||||
// `openInNewTab` / `redirect` route the sink through navigationUtils'
|
||||
// barriers (scheme allowlist, userinfo rejection, AF-1 backslash
|
||||
// rejection) — strictly stronger than master PR #40546's `sanitizeUrl`
|
||||
// wrap, which only rejects `javascript:` / `data:` / `vbscript:`.
|
||||
openInNewTab(url);
|
||||
} else {
|
||||
window.location.href = sanitizeUrl(url);
|
||||
redirect(url);
|
||||
}
|
||||
};
|
||||
const formDataWithDefaults = {
|
||||
|
||||
@@ -31,7 +31,7 @@ import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { SqlLabRootState } from 'src/SqlLab/types';
|
||||
import { ViewLocations } from 'src/SqlLab/contributions';
|
||||
import PanelToolbar from 'src/components/PanelToolbar';
|
||||
import { useViews } from 'src/core';
|
||||
import { views } from 'src/core';
|
||||
import { resolveView } from 'src/core/views';
|
||||
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
|
||||
import useLogAction from 'src/logger/useLogAction';
|
||||
@@ -107,7 +107,7 @@ const SouthPane = ({
|
||||
const editorId = tabViewId ?? id;
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const viewItems = useViews(ViewLocations.sqllab.panels) || [];
|
||||
const viewItems = views.getViews(ViewLocations.sqllab.panels) || [];
|
||||
const { offline, tables } = useSelector(
|
||||
({ sqlLab: { offline, tables } }: SqlLabRootState) => ({
|
||||
offline,
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Flex } from '@superset-ui/core/components';
|
||||
import ViewListExtension from 'src/components/ViewListExtension';
|
||||
import { useViews } from 'src/core';
|
||||
import { views } from 'src/core';
|
||||
import { SQL_EDITOR_STATUSBAR_HEIGHT } from 'src/SqlLab/constants';
|
||||
import { ViewLocations } from 'src/SqlLab/contributions';
|
||||
|
||||
@@ -38,7 +38,7 @@ const Container = styled(Flex)`
|
||||
`;
|
||||
|
||||
const StatusBar = () => {
|
||||
const statusBarViews = useViews(ViewLocations.sqllab.statusBar) || [];
|
||||
const statusBarViews = views.getViews(ViewLocations.sqllab.statusBar) || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -79,7 +79,7 @@ import {
|
||||
} from 'src/database/actions';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { clearDatasetCache } from 'src/utils/cachedSupersetGet';
|
||||
import { makeUrl } from 'src/utils/pathUtils';
|
||||
import { makeUrl, openInNewTab } from 'src/utils/navigationUtils';
|
||||
import {
|
||||
OwnerSelectLabel,
|
||||
OWNER_TEXT_LABEL_PROP,
|
||||
@@ -1158,7 +1158,10 @@ class DatasourceEditor extends PureComponent<
|
||||
}
|
||||
|
||||
openOnSqlLab() {
|
||||
window.open(this.getSQLLabUrl(), '_blank', 'noopener,noreferrer');
|
||||
// `getSQLLabUrl()` already runs the path through `makeUrl`; `openInNewTab`
|
||||
// re-applies `ensureAppRoot`, which is idempotent on already-prefixed
|
||||
// paths (see navigationUtils.appRoot.test.tsx).
|
||||
openInNewTab(this.getSQLLabUrl());
|
||||
}
|
||||
|
||||
tableChangeAndSyncMetadata() {
|
||||
|
||||
@@ -66,7 +66,7 @@ test('renders single dashboard link correctly', () => {
|
||||
|
||||
const link = screen.getByText('Sales Dashboard');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link.closest('a')).toHaveAttribute('href', '/superset/dashboard/1/');
|
||||
expect(link.closest('a')).toHaveAttribute('href', '/dashboard/1/');
|
||||
expect(link.closest('a')).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
@@ -98,9 +98,9 @@ test('links have correct href attributes', () => {
|
||||
.getByText(', Very Long Dashboard Name That Should Be Truncated')
|
||||
.closest('a');
|
||||
|
||||
expect(salesLink).toHaveAttribute('href', '/superset/dashboard/1/');
|
||||
expect(analyticsLink).toHaveAttribute('href', '/superset/dashboard/2/');
|
||||
expect(longNameLink).toHaveAttribute('href', '/superset/dashboard/3/');
|
||||
expect(salesLink).toHaveAttribute('href', '/dashboard/1/');
|
||||
expect(analyticsLink).toHaveAttribute('href', '/dashboard/2/');
|
||||
expect(longNameLink).toHaveAttribute('href', '/dashboard/3/');
|
||||
});
|
||||
|
||||
test('applies correct styling classes', () => {
|
||||
@@ -124,5 +124,5 @@ test('handles dashboard with empty title', () => {
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveTextContent('');
|
||||
expect(link).toHaveAttribute('href', '/superset/dashboard/1/');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/1/');
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ const DashboardLinksExternal = ({
|
||||
{dashboards.map((dashboard, index) => (
|
||||
<GenericLink
|
||||
key={dashboard.id}
|
||||
to={`/superset/dashboard/${dashboard.id}/`}
|
||||
to={`/dashboard/${dashboard.id}/`}
|
||||
target="_blank"
|
||||
>
|
||||
{index === 0
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { DatasourceType, isFeatureEnabled } from '@superset-ui/core';
|
||||
import * as getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import {
|
||||
createProps,
|
||||
DATASOURCE_ENDPOINT,
|
||||
@@ -821,3 +822,57 @@ test('calculated column search is case-insensitive', async () => {
|
||||
expect(screen.getByDisplayValue('upper_name')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('Open in SQL lab href is single-prefixed under subdirectory deployment', () => {
|
||||
// The Open-in-SQL-Lab link's href is produced by `getSQLLabUrl()`:
|
||||
// return makeUrl(`/sqllab/?${queryParams.toString()}`);
|
||||
// `makeUrl` is the idempotent app-root prefix helper from
|
||||
// `src/utils/navigationUtils`. Rendering the link requires both the
|
||||
// virtual datasourceType state AND a populated Redux `database.queryResult`
|
||||
// slice (which is not part of the default test reducer tree). Calling
|
||||
// `makeUrl` directly with a `/superset` mock exercises the exact path the
|
||||
// component takes and pins the dedupe invariant for the underlying helper.
|
||||
const applicationRootSpy = jest
|
||||
.spyOn(getBootstrapData, 'applicationRoot')
|
||||
.mockReturnValue('/superset');
|
||||
try {
|
||||
const { makeUrl } = jest.requireActual('src/utils/navigationUtils');
|
||||
const queryParams = new URLSearchParams({
|
||||
dbid: '1',
|
||||
sql: 'SELECT * FROM users',
|
||||
name: 'Vehicle Sales',
|
||||
schema: 'public',
|
||||
autorun: 'true',
|
||||
isDataset: 'true',
|
||||
});
|
||||
const url = makeUrl(`/sqllab/?${queryParams.toString()}`);
|
||||
expect(url).toMatch(/^\/superset\/sqllab\/\?/);
|
||||
expect(url).not.toMatch(/\/superset\/superset\//);
|
||||
} finally {
|
||||
applicationRootSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('DatasourceEditor source pins getSQLLabUrl/openOnSqlLab to the makeUrl + openInNewTab helpers', () => {
|
||||
// Source-pin: lock the exact two-line shape the runtime behaviour depends
|
||||
// on. `getSQLLabUrl` MUST wrap its `/sqllab/?...` path in `makeUrl` so the
|
||||
// Layer-2 idempotent prefix runs at the click boundary; `openOnSqlLab`
|
||||
// MUST delegate to `openInNewTab` so `ensureAppRoot` runs again (idempotent
|
||||
// dedupe, see `navigationUtils.appRoot.test.tsx`). A refactor that drops
|
||||
// either layer would let a doubled-prefix URL escape into a new tab.
|
||||
// eslint-disable-next-line global-require
|
||||
const { readFileSync } = require('fs');
|
||||
// eslint-disable-next-line global-require
|
||||
const { join } = require('path');
|
||||
const src = readFileSync(
|
||||
join(__dirname, '..', 'DatasourceEditor.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
expect(src).toMatch(
|
||||
/return makeUrl\(`\/sqllab\/\?\$\{queryParams\.toString\(\)\}`\);/,
|
||||
);
|
||||
expect(src).toMatch(/openInNewTab\(this\.getSQLLabUrl\(\)\);/);
|
||||
expect(src).toMatch(
|
||||
/import \{ makeUrl, openInNewTab \} from 'src\/utils\/navigationUtils';/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import getOwnerName from 'src/utils/getOwnerName';
|
||||
import { Avatar, AvatarGroup, Tooltip } from '@superset-ui/core/components';
|
||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
||||
import { ensureAppRoot } from 'src/utils/navigationUtils';
|
||||
import { getRandomColor } from './utils';
|
||||
import type { FacePileProps } from './types';
|
||||
|
||||
|
||||
@@ -68,10 +68,9 @@ test('should render the link with just one item', () => {
|
||||
],
|
||||
});
|
||||
expect(screen.getByText('Test dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link')).toHaveAttribute(
|
||||
'href',
|
||||
`/superset/dashboard/1`,
|
||||
);
|
||||
// Slice 3c: default `linkPrefix` is now `/dashboard/` (post-route_base);
|
||||
// legacy `/superset/dashboard/...` was the pre-collapse route.
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', `/dashboard/1`);
|
||||
});
|
||||
|
||||
test('should render a custom prefix link', () => {
|
||||
|
||||
@@ -65,7 +65,7 @@ const StyledCrossLinks = styled.div`
|
||||
function CrossLinks({
|
||||
crossLinks,
|
||||
maxLinks = 20,
|
||||
linkPrefix = '/superset/dashboard/',
|
||||
linkPrefix = '/dashboard/',
|
||||
external = false,
|
||||
}: CrossLinksProps) {
|
||||
const [crossLinksRef, plusRef, elementsTruncated, hasHiddenElements] =
|
||||
|
||||
@@ -17,12 +17,11 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { useMenu } from 'src/core';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Button, Divider, Dropdown } from '@superset-ui/core/components';
|
||||
import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { commands } from 'src/core';
|
||||
import { commands, menus } from 'src/core';
|
||||
|
||||
export interface PanelToolbarProps {
|
||||
viewId: string;
|
||||
@@ -36,7 +35,7 @@ const PanelToolbar = ({
|
||||
defaultSecondaryActions,
|
||||
}: PanelToolbarProps) => {
|
||||
const theme = useTheme();
|
||||
const menu = useMenu(viewId);
|
||||
const menu = menus.getMenu(viewId);
|
||||
|
||||
const primaryItems = menu?.primary || [];
|
||||
const secondaryItems = menu?.secondary || [];
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { ExportStatus, StreamingProgress } from './StreamingExportModal';
|
||||
import { makeUrl } from 'src/utils/pathUtils';
|
||||
import { makeUrl } from 'src/utils/navigationUtils';
|
||||
import { applicationRoot } from 'src/utils/getBootstrapData';
|
||||
|
||||
interface UseStreamingExportOptions {
|
||||
@@ -36,8 +36,8 @@ interface StreamingExportParams {
|
||||
* The API endpoint URL for the export request.
|
||||
*
|
||||
* URLs should be prefixed with the application root at the call site using
|
||||
* `makeUrl()` from 'src/utils/pathUtils'. This ensures proper handling for
|
||||
* subdirectory deployments (e.g., /superset/api/v1/...).
|
||||
* `makeUrl()` from `src/utils/navigationUtils`. This ensures proper handling
|
||||
* for subdirectory deployments (e.g., /superset/api/v1/...).
|
||||
*
|
||||
* A defensive guard (`ensureUrlPrefix`) will apply the prefix if missing,
|
||||
* but callers should not rely on this fallback behavior.
|
||||
|
||||
@@ -82,7 +82,7 @@ const SupersetTag = ({
|
||||
{' '}
|
||||
{id ? (
|
||||
<Link
|
||||
to={`/superset/all_entities/?id=${id}`}
|
||||
to={`/all_entities/?id=${id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -39,7 +39,8 @@ jest.mock('./EditorProviders', () => ({
|
||||
getInstance: () => ({
|
||||
getProvider: jest.fn().mockReturnValue(undefined),
|
||||
hasProvider: jest.fn().mockReturnValue(false),
|
||||
subscribe: jest.fn().mockReturnValue(() => {}),
|
||||
onDidRegister: jest.fn().mockReturnValue({ dispose: jest.fn() }),
|
||||
onDidUnregister: jest.fn().mockReturnValue({ dispose: jest.fn() }),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -26,12 +26,13 @@
|
||||
* back to the default Ace editor.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore, forwardRef } from 'react';
|
||||
import { useState, useEffect, forwardRef } from 'react';
|
||||
import type { editors } from '@apache-superset/core';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import EditorProviders from './EditorProviders';
|
||||
import AceEditorProvider from './AceEditorProvider';
|
||||
|
||||
type EditorLanguage = editors.EditorLanguage;
|
||||
type EditorProps = editors.EditorProps;
|
||||
type EditorHandle = editors.EditorHandle;
|
||||
|
||||
@@ -41,6 +42,49 @@ type EditorHandle = editors.EditorHandle;
|
||||
*/
|
||||
export type EditorHostProps = EditorProps;
|
||||
|
||||
/**
|
||||
* Hook to track editor provider changes.
|
||||
* Returns the provider for the specified language and re-renders when it changes.
|
||||
*/
|
||||
const useEditorProvider = (language: EditorLanguage) => {
|
||||
const manager = EditorProviders.getInstance();
|
||||
const [provider, setProvider] = useState(() => manager.getProvider(language));
|
||||
|
||||
useEffect(() => {
|
||||
// Helper to safely update provider state, always fetching latest from manager
|
||||
const updateProvider = () => {
|
||||
setProvider(prev => {
|
||||
const current = manager.getProvider(language);
|
||||
return current !== prev ? current : prev;
|
||||
});
|
||||
};
|
||||
|
||||
// Subscribe to provider changes
|
||||
const registerDisposable = manager.onDidRegister(event => {
|
||||
if (event.editor.languages.includes(language)) {
|
||||
updateProvider();
|
||||
}
|
||||
});
|
||||
|
||||
const unregisterDisposable = manager.onDidUnregister(event => {
|
||||
if (event.editor.languages.includes(language)) {
|
||||
updateProvider();
|
||||
}
|
||||
});
|
||||
|
||||
// Check for provider on mount (in case it was registered before this component mounted)
|
||||
updateProvider();
|
||||
|
||||
return () => {
|
||||
registerDisposable.dispose();
|
||||
unregisterDisposable.dispose();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [language, manager]);
|
||||
|
||||
return provider;
|
||||
};
|
||||
|
||||
/**
|
||||
* EditorHost component that dynamically resolves and renders the appropriate editor.
|
||||
*
|
||||
@@ -62,12 +106,7 @@ export type EditorHostProps = EditorProps;
|
||||
const EditorHost = forwardRef<EditorHandle, EditorHostProps>((props, ref) => {
|
||||
const { language } = props;
|
||||
const theme = useTheme();
|
||||
const manager = EditorProviders.getInstance();
|
||||
const provider = useSyncExternalStore(
|
||||
manager.subscribe,
|
||||
() => manager.getProvider(language),
|
||||
() => undefined,
|
||||
);
|
||||
const provider = useEditorProvider(language);
|
||||
|
||||
// Merge theme into props
|
||||
const propsWithTheme = { ...props, theme };
|
||||
|
||||
@@ -93,17 +93,6 @@ class EditorProviders {
|
||||
*/
|
||||
private unregisterEmitter = new EventEmitter<EditorUnregisteredEvent>();
|
||||
|
||||
private syncListeners: Set<() => void> = new Set();
|
||||
|
||||
/**
|
||||
* Stable-reference subscribe function for useSyncExternalStore.
|
||||
* Defined as an arrow property so the reference is bound to this instance at construction.
|
||||
*/
|
||||
public subscribe = (listener: () => void): (() => void) => {
|
||||
this.syncListeners.add(listener);
|
||||
return () => this.syncListeners.delete(listener);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
private constructor() {
|
||||
// Private constructor for singleton pattern
|
||||
@@ -156,7 +145,6 @@ class EditorProviders {
|
||||
|
||||
// Fire registration event
|
||||
this.registerEmitter.fire({ editor });
|
||||
this.syncListeners.forEach(l => l());
|
||||
|
||||
// Return disposable for cleanup
|
||||
return new Disposable(() => {
|
||||
@@ -188,7 +176,6 @@ class EditorProviders {
|
||||
|
||||
// Fire unregistration event
|
||||
this.unregisterEmitter.fire({ editor });
|
||||
this.syncListeners.forEach(l => l());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,7 +234,6 @@ class EditorProviders {
|
||||
public reset(): void {
|
||||
this.providers.clear();
|
||||
this.languageToProvider.clear();
|
||||
this.syncListeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
* and resolution functions declared in the API types.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { editors as editorsApi } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
import EditorProviders from './EditorProviders';
|
||||
@@ -110,23 +109,6 @@ export const onDidUnregisterEditor = (
|
||||
return manager.onDidUnregister(listener);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that returns the editor provider for a specific language and re-renders when it changes.
|
||||
*
|
||||
* @param language The language to get an editor for
|
||||
* @returns The editor provider or undefined if no extension provides one
|
||||
*/
|
||||
export const useEditor = (
|
||||
language: EditorLanguage,
|
||||
): EditorProvider | undefined => {
|
||||
const manager = EditorProviders.getInstance();
|
||||
return useSyncExternalStore(
|
||||
manager.subscribe,
|
||||
() => manager.getProvider(language),
|
||||
() => undefined,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Editors API object for use in the extension system.
|
||||
*/
|
||||
|
||||
@@ -24,14 +24,11 @@
|
||||
* Extensions register menu items as side effects at import time.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import type { menus as menusApi } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
|
||||
type MenuItem = menusApi.MenuItem;
|
||||
type Menu = menusApi.Menu;
|
||||
type MenuItemRegisteredEvent = menusApi.MenuItemRegisteredEvent;
|
||||
type MenuItemUnregisteredEvent = menusApi.MenuItemUnregisteredEvent;
|
||||
|
||||
type StoredMenuItem = {
|
||||
item: MenuItem;
|
||||
@@ -41,27 +38,6 @@ type StoredMenuItem = {
|
||||
|
||||
const menuItems: StoredMenuItem[] = [];
|
||||
|
||||
const syncListeners = new Set<() => void>();
|
||||
const subscribe = (listener: () => void) => {
|
||||
syncListeners.add(listener);
|
||||
return () => syncListeners.delete(listener);
|
||||
};
|
||||
|
||||
const registerListeners = new Set<(e: MenuItemRegisteredEvent) => void>();
|
||||
const unregisterListeners = new Set<(e: MenuItemUnregisteredEvent) => void>();
|
||||
|
||||
const menuCache = new Map<string, Menu | undefined>();
|
||||
const notifyRegister = (event: MenuItemRegisteredEvent) => {
|
||||
menuCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
registerListeners.forEach(l => l(event));
|
||||
};
|
||||
const notifyUnregister = (event: MenuItemUnregisteredEvent) => {
|
||||
menuCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
unregisterListeners.forEach(l => l(event));
|
||||
};
|
||||
|
||||
const registerMenuItem: typeof menusApi.registerMenuItem = (
|
||||
item: MenuItem,
|
||||
location: string,
|
||||
@@ -69,13 +45,11 @@ const registerMenuItem: typeof menusApi.registerMenuItem = (
|
||||
): Disposable => {
|
||||
const stored: StoredMenuItem = { item, location, group };
|
||||
menuItems.push(stored);
|
||||
notifyRegister({ item, location, group });
|
||||
return new Disposable(() => {
|
||||
const index = menuItems.indexOf(stored);
|
||||
if (index >= 0) {
|
||||
menuItems.splice(index, 1);
|
||||
}
|
||||
notifyUnregister({ item, location, group });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -103,34 +77,7 @@ const getMenu: typeof menusApi.getMenu = (
|
||||
return result;
|
||||
};
|
||||
|
||||
export const useMenu = (location: string): Menu | undefined =>
|
||||
useSyncExternalStore(
|
||||
subscribe,
|
||||
() => {
|
||||
if (!menuCache.has(location)) {
|
||||
menuCache.set(location, getMenu(location));
|
||||
}
|
||||
return menuCache.get(location);
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = (
|
||||
listener: (e: MenuItemRegisteredEvent) => void,
|
||||
): Disposable => {
|
||||
registerListeners.add(listener);
|
||||
return new Disposable(() => registerListeners.delete(listener));
|
||||
};
|
||||
|
||||
export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem =
|
||||
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable => {
|
||||
unregisterListeners.add(listener);
|
||||
return new Disposable(() => unregisterListeners.delete(listener));
|
||||
};
|
||||
|
||||
export const menus: typeof menusApi = {
|
||||
registerMenuItem,
|
||||
getMenu,
|
||||
onDidRegisterMenuItem,
|
||||
onDidUnregisterMenuItem,
|
||||
};
|
||||
|
||||
@@ -24,15 +24,13 @@
|
||||
* Extensions register views as side effects at import time.
|
||||
*/
|
||||
|
||||
import React, { ReactElement, useSyncExternalStore } from 'react';
|
||||
import React, { ReactElement } from 'react';
|
||||
import type { views as viewsApi } from '@apache-superset/core';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder';
|
||||
import { Disposable } from '../models';
|
||||
|
||||
type View = viewsApi.View;
|
||||
type ViewRegisteredEvent = viewsApi.ViewRegisteredEvent;
|
||||
type ViewUnregisteredEvent = viewsApi.ViewUnregisteredEvent;
|
||||
|
||||
const viewRegistry: Map<
|
||||
string,
|
||||
@@ -41,27 +39,6 @@ const viewRegistry: Map<
|
||||
|
||||
const locationIndex: Map<string, Set<string>> = new Map();
|
||||
|
||||
const syncListeners = new Set<() => void>();
|
||||
const subscribe = (listener: () => void) => {
|
||||
syncListeners.add(listener);
|
||||
return () => syncListeners.delete(listener);
|
||||
};
|
||||
|
||||
const registerListeners = new Set<(e: ViewRegisteredEvent) => void>();
|
||||
const unregisterListeners = new Set<(e: ViewUnregisteredEvent) => void>();
|
||||
|
||||
const viewsCache = new Map<string, View[] | undefined>();
|
||||
const notifyRegister = (event: ViewRegisteredEvent) => {
|
||||
viewsCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
registerListeners.forEach(l => l(event));
|
||||
};
|
||||
const notifyUnregister = (event: ViewUnregisteredEvent) => {
|
||||
viewsCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
unregisterListeners.forEach(l => l(event));
|
||||
};
|
||||
|
||||
const registerView: typeof viewsApi.registerView = (
|
||||
view: View,
|
||||
location: string,
|
||||
@@ -74,12 +51,10 @@ const registerView: typeof viewsApi.registerView = (
|
||||
const ids = locationIndex.get(location) ?? new Set();
|
||||
ids.add(id);
|
||||
locationIndex.set(location, ids);
|
||||
notifyRegister({ view, location });
|
||||
|
||||
return new Disposable(() => {
|
||||
viewRegistry.delete(id);
|
||||
locationIndex.get(location)?.delete(id);
|
||||
notifyUnregister({ view, location });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -102,35 +77,7 @@ const getViews: typeof viewsApi.getViews = (
|
||||
.filter((c): c is View => !!c);
|
||||
};
|
||||
|
||||
export const useViews = (location: string): View[] | undefined =>
|
||||
useSyncExternalStore(
|
||||
subscribe,
|
||||
() => {
|
||||
if (!viewsCache.has(location)) {
|
||||
viewsCache.set(location, getViews(location));
|
||||
}
|
||||
return viewsCache.get(location);
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
export const onDidRegisterView: typeof viewsApi.onDidRegisterView = (
|
||||
listener: (e: ViewRegisteredEvent) => void,
|
||||
): Disposable => {
|
||||
registerListeners.add(listener);
|
||||
return new Disposable(() => registerListeners.delete(listener));
|
||||
};
|
||||
|
||||
export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = (
|
||||
listener: (e: ViewUnregisteredEvent) => void,
|
||||
): Disposable => {
|
||||
unregisterListeners.add(listener);
|
||||
return new Disposable(() => unregisterListeners.delete(listener));
|
||||
};
|
||||
|
||||
export const views: typeof viewsApi = {
|
||||
registerView,
|
||||
getViews,
|
||||
onDidRegisterView,
|
||||
onDidUnregisterView,
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
} from 'spec/fixtures/mockSliceEntities';
|
||||
import { emptyFilters } from 'spec/fixtures/mockDashboardFilters';
|
||||
import mockDashboardData from 'spec/fixtures/mockDashboardData';
|
||||
import { navigateTo } from 'src/utils/navigationUtils';
|
||||
import { navigateTo, navigateWithState } from 'src/utils/navigationUtils';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
@@ -72,6 +72,7 @@ jest.mock('src/utils/navigationUtils', () => ({
|
||||
|
||||
const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
const mockNavigateTo = navigateTo as jest.Mock;
|
||||
const mockNavigateWithState = navigateWithState as jest.Mock;
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('dashboardState actions', () => {
|
||||
@@ -253,7 +254,48 @@ describe('dashboardState actions', () => {
|
||||
|
||||
await waitFor(() => expect(postStub.mock.calls.length).toBe(1));
|
||||
expect(mockNavigateTo).toHaveBeenCalledWith(
|
||||
`/superset/dashboard/${newDashboardId}/`,
|
||||
`/dashboard/${newDashboardId}/`,
|
||||
);
|
||||
});
|
||||
|
||||
// Slice 8 step 1 — `navigateWithState` regression for the
|
||||
// dashboard-properties-changed save path. Two assertions in one shape:
|
||||
// (a) the emitted path is router-relative (`/dashboard/<id>/`), not
|
||||
// the pre-migration `/superset/dashboard/<id>/` literal that under
|
||||
// subdirectory deployment would double-prefix to
|
||||
// `/superset/superset/dashboard/<id>/`;
|
||||
// (b) the `event: 'dashboard_properties_changed'` history-state arg is
|
||||
// preserved verbatim. A previous attempt to swap `navigateWithState`
|
||||
// for a plain `navigateTo` would silently drop this state object and
|
||||
// the dashboard would lose its post-save UX cue.
|
||||
test('saves dashboard properties via navigateWithState with state preserved', async () => {
|
||||
const updatedId = 777;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardState: { hasUnsavedChanges: true },
|
||||
});
|
||||
|
||||
mockNavigateWithState.mockClear();
|
||||
putStub.mockRestore();
|
||||
putStub = jest.spyOn(SupersetClient, 'put').mockResolvedValue({
|
||||
json: {
|
||||
result: { ...mockDashboardData, id: updatedId, slug: null },
|
||||
last_modified_time: 0,
|
||||
},
|
||||
} as any);
|
||||
|
||||
const thunk = saveDashboardRequest(
|
||||
newDashboardData,
|
||||
updatedId,
|
||||
SAVE_TYPE_OVERWRITE,
|
||||
);
|
||||
await thunk(dispatch, getState);
|
||||
|
||||
await waitFor(() => expect(putStub.mock.calls.length).toBe(1));
|
||||
await waitFor(() => expect(mockNavigateWithState).toHaveBeenCalled());
|
||||
|
||||
expect(mockNavigateWithState).toHaveBeenCalledWith(
|
||||
`/dashboard/${updatedId}/`,
|
||||
{ event: 'dashboard_properties_changed' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -577,9 +577,7 @@ export function saveDashboardRequest(
|
||||
}),
|
||||
);
|
||||
dispatch(saveDashboardFinished());
|
||||
navigateTo(
|
||||
`/superset/dashboard/${(response.json as JsonObject).result?.id}/`,
|
||||
);
|
||||
navigateTo(`/dashboard/${(response.json as JsonObject).result?.id}/`);
|
||||
dispatch(addSuccessToast(t('This dashboard was saved successfully.')));
|
||||
return response;
|
||||
};
|
||||
@@ -632,7 +630,7 @@ export function saveDashboardRequest(
|
||||
}
|
||||
dispatch(saveDashboardFinished());
|
||||
// redirect to the new slug or id
|
||||
navigateWithState(`/superset/dashboard/${slug || id}/`, {
|
||||
navigateWithState(`/dashboard/${slug || id}/`, {
|
||||
event: 'dashboard_properties_changed',
|
||||
});
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export function fetchDatasourceMetadata(key: string) {
|
||||
}
|
||||
|
||||
return SupersetClient.get({
|
||||
endpoint: `/superset/fetch_datasource_metadata?datasourceKey=${key}`,
|
||||
endpoint: `/fetch_datasource_metadata?datasourceKey=${key}`,
|
||||
}).then(({ json }) => dispatch(setDatasource(json as Datasource, key)));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ import * as useNativeFiltersModule from './state';
|
||||
fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {});
|
||||
fetchMock.put('glob:*/api/v1/dashboard/*', {});
|
||||
// Add mock for logging endpoint
|
||||
fetchMock.post('glob:*/superset/log/?*', {});
|
||||
fetchMock.post('glob:*/log/?*', {});
|
||||
|
||||
jest.mock('src/dashboard/actions/dashboardState', () => ({
|
||||
...jest.requireActual('src/dashboard/actions/dashboardState'),
|
||||
|
||||
@@ -29,7 +29,7 @@ import * as chartCustomizationActions from '../../actions/chartCustomizationActi
|
||||
|
||||
fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {});
|
||||
fetchMock.put('glob:*/api/v1/dashboard/*/colors*', {});
|
||||
fetchMock.post('glob:*/superset/log/?*', {});
|
||||
fetchMock.post('glob:*/log/?*', {});
|
||||
|
||||
jest.mock('@visx/responsive', () => ({
|
||||
ParentSize: ({
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 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, userEvent } from 'spec/helpers/testing-library';
|
||||
import { VizType } from '@superset-ui/core';
|
||||
import mockState from 'spec/fixtures/mockState';
|
||||
import SliceHeaderControls, { SliceHeaderControlsProps } from '.';
|
||||
|
||||
// Subdirectory-specific regressions live here so the existing 676-line
|
||||
// SliceHeaderControls.test.tsx doesn't need to mock getBootstrapData.
|
||||
|
||||
// Slice 8 M7 verification gate (2026-06-01) — DO NOT switch this file to
|
||||
// `spec/helpers/withApplicationRoot.ts`. The fixture does
|
||||
// `jest.resetModules()` + dynamic `import('src/utils/getBootstrapData')` to
|
||||
// install a fixture-configured applicationRoot. But `SliceHeaderControls` is
|
||||
// imported statically at the top of this file; its transitive dependency
|
||||
// chain (`SliceHeaderControls` → `navigationUtils` → `pathUtils` →
|
||||
// `getBootstrapData::applicationRoot`) is bound to the pre-reset module
|
||||
// instance. After `withApplicationRoot('/superset')` resets modules and
|
||||
// re-imports getBootstrapData on the test side, the statically-imported
|
||||
// component continues to reach the OLD module whose `application_root` was
|
||||
// empty at first evaluation — so the rendered tree resolves
|
||||
// `applicationRoot()` to `''`, NOT `/superset`. Gate (a) of the M7 go/no-go
|
||||
// fails ("the rendered SliceHeaderControls tree must resolve
|
||||
// applicationRoot() to the fixture-configured value"). The hand-rolled
|
||||
// `jest.mock('src/utils/getBootstrapData', ...)` below remains until a
|
||||
// later slice either (i) defers the SliceHeaderControls import into the
|
||||
// withApplicationRoot callback or (ii) plumbs application_root through
|
||||
// React context rather than a module-scoped cache.
|
||||
|
||||
// Name must start with `mock` so Jest's hoisted jest.mock() factory may
|
||||
// reference it. `default` returns a static shape (not mockApplicationRoot)
|
||||
// because consumers like setupClient.ts call getBootstrapData() at import
|
||||
// time — calling mockApplicationRoot inside `default` hits TDZ.
|
||||
const mockApplicationRoot = jest.fn<string, []>(() => '');
|
||||
|
||||
jest.mock('src/utils/getBootstrapData', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
common: { application_root: '', static_assets_prefix: '' },
|
||||
}),
|
||||
applicationRoot: () => mockApplicationRoot(),
|
||||
staticAssetsPrefix: () => '',
|
||||
}));
|
||||
|
||||
const SLICE_ID = 371;
|
||||
|
||||
const buildProps = (): SliceHeaderControlsProps =>
|
||||
({
|
||||
addDangerToast: jest.fn(),
|
||||
addSuccessToast: jest.fn(),
|
||||
exploreChart: jest.fn(),
|
||||
exportCSV: jest.fn(),
|
||||
exportFullCSV: jest.fn(),
|
||||
exportXLSX: jest.fn(),
|
||||
exportFullXLSX: jest.fn(),
|
||||
exportPivotExcel: jest.fn(),
|
||||
forceRefresh: jest.fn(),
|
||||
handleToggleFullSize: jest.fn(),
|
||||
toggleExpandSlice: jest.fn(),
|
||||
logEvent: jest.fn(),
|
||||
logExploreChart: jest.fn(),
|
||||
slice: {
|
||||
slice_id: SLICE_ID,
|
||||
slice_url: '/explore/?form_data=%7B%22slice_id%22%3A%20371%7D',
|
||||
slice_name: 'Subdirectory regression chart',
|
||||
slice_description: '',
|
||||
form_data: {
|
||||
slice_id: SLICE_ID,
|
||||
datasource: '58__table',
|
||||
viz_type: VizType.Sunburst,
|
||||
},
|
||||
viz_type: VizType.Sunburst,
|
||||
datasource: '58__table',
|
||||
description: '',
|
||||
description_markeddown: '',
|
||||
owners: [],
|
||||
modified: '',
|
||||
changed_on: 0,
|
||||
},
|
||||
isCached: [false],
|
||||
isExpanded: false,
|
||||
cachedDttm: [''],
|
||||
updatedDttm: 0,
|
||||
supersetCanExplore: true,
|
||||
supersetCanCSV: true,
|
||||
componentId: 'CHART-subdir',
|
||||
dashboardId: 26,
|
||||
isFullSize: false,
|
||||
chartStatus: 'rendered',
|
||||
showControls: true,
|
||||
supersetCanShare: true,
|
||||
formData: {
|
||||
slice_id: SLICE_ID,
|
||||
datasource: '58__table',
|
||||
viz_type: VizType.Sunburst,
|
||||
},
|
||||
exploreUrl: '/explore/?dashboard_page_id=abc&slice_id=371',
|
||||
defaultOpen: true,
|
||||
}) as unknown as SliceHeaderControlsProps;
|
||||
|
||||
const renderControls = (): void => {
|
||||
render(<SliceHeaderControls {...buildProps()} />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
...mockState,
|
||||
user: {
|
||||
...mockState.user,
|
||||
roles: { Admin: [['can_samples', 'Datasource']] },
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('SliceHeaderControls — Cmd-click "Edit chart" under subdirectory deployment', () => {
|
||||
let openSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
mockApplicationRoot.mockReturnValue('');
|
||||
openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('opens the unprefixed exploreUrl when application root is empty', async () => {
|
||||
mockApplicationRoot.mockReturnValue('');
|
||||
renderControls();
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'More Options' }));
|
||||
const editChart = await screen.findByText('Edit chart');
|
||||
userEvent.click(editChart, { metaKey: true });
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'/explore/?dashboard_page_id=abc&slice_id=371',
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
});
|
||||
|
||||
test('opens the prefixed exploreUrl when deployed under a subdirectory', async () => {
|
||||
mockApplicationRoot.mockReturnValue('/superset');
|
||||
renderControls();
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'More Options' }));
|
||||
const editChart = await screen.findByText('Edit chart');
|
||||
userEvent.click(editChart, { metaKey: true });
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'/superset/explore/?dashboard_page_id=abc&slice_id=371',
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,7 @@ import { useDrillDetailMenuItems } from 'src/components/Chart/useDrillDetailMenu
|
||||
import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
|
||||
import { MenuKeys, RootState } from 'src/dashboard/types';
|
||||
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
|
||||
import { openInNewTab } from 'src/utils/navigationUtils';
|
||||
import { usePermissions } from 'src/hooks/usePermissions';
|
||||
import { useDatasetDrillInfo } from 'src/hooks/apiResources/datasets';
|
||||
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
|
||||
@@ -263,7 +264,7 @@ const SliceHeaderControls = (
|
||||
props.logExploreChart?.(props.slice.slice_id);
|
||||
if (domEvent.metaKey || domEvent.ctrlKey) {
|
||||
domEvent.preventDefault();
|
||||
window.open(props.exploreUrl, '_blank');
|
||||
openInNewTab(props.exploreUrl);
|
||||
} else {
|
||||
history.push(props.exploreUrl);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ const PERMALINK_PAYLOAD = {
|
||||
key: '123',
|
||||
url: 'http://fakeurl.com/123',
|
||||
};
|
||||
// rewritePermalinkOrigin substitutes window.location.origin (jsdom: http://localhost)
|
||||
// for the permalink's origin while preserving the path. See urlUtils.ts.
|
||||
const REWRITTEN_URL = `${window.location.origin}/123`;
|
||||
const FILTER_STATE_PAYLOAD = {
|
||||
value: '{}',
|
||||
};
|
||||
@@ -58,9 +61,7 @@ test('renders overlay on click', async () => {
|
||||
test('obtains short url', async () => {
|
||||
render(<URLShortLinkButton {...props} />, { useRedux: true });
|
||||
userEvent.click(screen.getByRole('button'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
PERMALINK_PAYLOAD.url,
|
||||
);
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(REWRITTEN_URL);
|
||||
});
|
||||
|
||||
test('creates email anchor', async () => {
|
||||
@@ -78,7 +79,7 @@ test('creates email anchor', async () => {
|
||||
},
|
||||
);
|
||||
|
||||
const href = `mailto:?Subject=${subject}%20&Body=${content}${PERMALINK_PAYLOAD.url}`;
|
||||
const href = `mailto:?Subject=${subject}%20&Body=${content}${REWRITTEN_URL}`;
|
||||
userEvent.click(screen.getByRole('button'));
|
||||
expect(await screen.findByRole('link')).toHaveAttribute('href', href);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 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 { FC } from 'react';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
|
||||
// Tab.tsx is statically imported below; the mock pattern intercepts
|
||||
// applicationRoot() rather than relying on withApplicationRoot (which is for
|
||||
// dynamic-import unit tests).
|
||||
|
||||
const mockApplicationRoot = jest.fn<string, []>(() => '');
|
||||
|
||||
jest.mock('src/utils/getBootstrapData', () => {
|
||||
const actual = jest.requireActual<
|
||||
typeof import('src/utils/getBootstrapData')
|
||||
>('src/utils/getBootstrapData');
|
||||
return {
|
||||
__esModule: true,
|
||||
default: actual.default,
|
||||
applicationRoot: () => mockApplicationRoot(),
|
||||
staticAssetsPrefix: actual.staticAssetsPrefix,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('src/dashboard/util/getChartIdsFromComponent', () =>
|
||||
jest.fn(() => []),
|
||||
);
|
||||
jest.mock('src/dashboard/containers/DashboardComponent', () =>
|
||||
jest.fn(() => <div data-test="DashboardComponent" />),
|
||||
);
|
||||
jest.mock('@superset-ui/core/components/EditableTitle', () => ({
|
||||
__esModule: true,
|
||||
EditableTitle: jest.fn(() => <div data-test="EditableTitle" />),
|
||||
}));
|
||||
jest.mock('src/dashboard/components/dnd/DragDroppable', () => ({
|
||||
...jest.requireActual('src/dashboard/components/dnd/DragDroppable'),
|
||||
Droppable: jest.fn(props => (
|
||||
<div>{props.children ? props.children({}) : null}</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import ActualTab from './Tab';
|
||||
|
||||
const Tab = ActualTab as unknown as FC<Record<string, unknown>>;
|
||||
|
||||
const DASHBOARD_ID = 23;
|
||||
|
||||
const buildProps = () => ({
|
||||
id: 'TAB-empty-',
|
||||
parentId: 'TABS-empty-',
|
||||
depth: 2,
|
||||
index: 0,
|
||||
renderType: 'RENDER_TAB_CONTENT',
|
||||
availableColumnCount: 12,
|
||||
columnWidth: 120,
|
||||
isFocused: false,
|
||||
component: {
|
||||
children: [],
|
||||
id: 'TAB-empty-',
|
||||
meta: { text: 'Empty Tab' },
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'TABS-empty-'],
|
||||
type: 'TAB',
|
||||
},
|
||||
parentComponent: {
|
||||
children: ['TAB-empty-'],
|
||||
id: 'TABS-empty-',
|
||||
meta: {},
|
||||
parents: ['ROOT_ID', 'GRID_ID'],
|
||||
type: 'TABS',
|
||||
},
|
||||
editMode: true,
|
||||
embeddedMode: false,
|
||||
undoLength: 0,
|
||||
redoLength: 0,
|
||||
filters: {},
|
||||
directPathToChild: [],
|
||||
directPathLastUpdated: 0,
|
||||
dashboardId: DASHBOARD_ID,
|
||||
focusedFilterScope: null,
|
||||
isComponentVisible: true,
|
||||
onDropOnTab: jest.fn(),
|
||||
handleComponentDrop: jest.fn(),
|
||||
updateComponents: jest.fn(),
|
||||
setDirectPathToChild: jest.fn(),
|
||||
onResizeStart: jest.fn(),
|
||||
onResize: jest.fn(),
|
||||
onResizeStop: jest.fn(),
|
||||
});
|
||||
|
||||
const renderEmptyEditModeTab = () =>
|
||||
render(<Tab {...buildProps()} />, {
|
||||
useRedux: true,
|
||||
useDnd: true,
|
||||
initialState: {
|
||||
dashboardInfo: { dash_edit_perm: true },
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockApplicationRoot.mockReturnValue('');
|
||||
});
|
||||
|
||||
test('Tab — empty edit-mode "create a new chart" link is unprefixed when application root is empty', () => {
|
||||
mockApplicationRoot.mockReturnValue('');
|
||||
renderEmptyEditModeTab();
|
||||
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'create a new chart' }),
|
||||
).toHaveAttribute('href', `/chart/add?dashboard_id=${DASHBOARD_ID}`);
|
||||
});
|
||||
|
||||
test('Tab — empty edit-mode "create a new chart" link carries the application root under subdirectory deployment', () => {
|
||||
mockApplicationRoot.mockReturnValue('/superset');
|
||||
renderEmptyEditModeTab();
|
||||
|
||||
// Single prefix — not /superset/superset/ — verifying ensureAppRoot's
|
||||
// dedupe boundary holds against the path's leading slash.
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'create a new chart' }),
|
||||
).toHaveAttribute('href', `/superset/chart/add?dashboard_id=${DASHBOARD_ID}`);
|
||||
});
|
||||
|
||||
test('Tab — empty edit-mode "create a new chart" link prefixes correctly for nested subdirectory roots', () => {
|
||||
mockApplicationRoot.mockReturnValue('/a/b/c');
|
||||
renderEmptyEditModeTab();
|
||||
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'create a new chart' }),
|
||||
).toHaveAttribute('href', `/a/b/c/chart/add?dashboard_id=${DASHBOARD_ID}`);
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
|
||||
import { EditableTitle } from '@superset-ui/core/components';
|
||||
import { setEditMode, onRefresh } from 'src/dashboard/actions/dashboardState';
|
||||
import * as getBootstrapData from 'src/utils/getBootstrapData';
|
||||
|
||||
import type { FC } from 'react';
|
||||
import ActualTab from './Tab';
|
||||
@@ -488,6 +489,36 @@ test('Render tab content with no children, editMode: true, canEdit: true', () =>
|
||||
).toHaveAttribute('href', '/chart/add?dashboard_id=23');
|
||||
});
|
||||
|
||||
test('empty-tab "create a new chart" link is single-prefixed under subdirectory deployment', () => {
|
||||
// The empty-tab CTA composes the chart-add URL via ensureAppRoot. Under
|
||||
// SUPERSET_APP_ROOT=/superset the rendered href must be exactly
|
||||
// `/superset/chart/add?dashboard_id=23` — not `/chart/add?…` (no prefix)
|
||||
// and not `/superset/superset/chart/add?…` (double prefix). The link uses
|
||||
// target="_blank", so basename routing does NOT re-apply the prefix.
|
||||
const applicationRootSpy = jest
|
||||
.spyOn(getBootstrapData, 'applicationRoot')
|
||||
.mockReturnValue('/superset');
|
||||
|
||||
try {
|
||||
const props = createProps();
|
||||
props.editMode = true;
|
||||
props.component.children = [];
|
||||
render(<Tab {...props} />, {
|
||||
useRedux: true,
|
||||
useDnd: true,
|
||||
initialState: {
|
||||
dashboardInfo: { dash_edit_perm: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'create a new chart' }),
|
||||
).toHaveAttribute('href', '/superset/chart/add?dashboard_id=23');
|
||||
} finally {
|
||||
applicationRootSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('Drag to empty state, editMode: true, canEdit: true', async () => {
|
||||
const props = createProps();
|
||||
props.editMode = true;
|
||||
|
||||
@@ -36,6 +36,7 @@ import getChartIdsFromComponent from 'src/dashboard/util/getChartIdsFromComponen
|
||||
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
|
||||
import AnchorLink from 'src/dashboard/components/AnchorLink';
|
||||
import { Typography } from '@superset-ui/core/components/Typography';
|
||||
import { ensureAppRoot } from 'src/utils/navigationUtils';
|
||||
import {
|
||||
useIsAutoRefreshing,
|
||||
useIsRefreshInFlight,
|
||||
@@ -333,7 +334,9 @@ const Tab = (props: TabProps): ReactElement => {
|
||||
<span>
|
||||
{t('You can')}{' '}
|
||||
<Typography.Link
|
||||
href={`/chart/add?dashboard_id=${dashboardId}`}
|
||||
href={ensureAppRoot(
|
||||
`/chart/add?dashboard_id=${dashboardId}`,
|
||||
)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Closes test-gap P1-5 from the 2026-06-02 subdirectory test-gap audit.
|
||||
//
|
||||
// `FilterBar/index.tsx::publishDataMask` is one of the five sanctioned
|
||||
// `applicationRoot()` callers (memory `project_supersetclient_approot_dedupe`).
|
||||
// It runs after a filter mutation to push the updated filter cache key into
|
||||
// the URL via `history.replace`. Two appRoot-aware operations gate that
|
||||
// replace:
|
||||
//
|
||||
// 1. The path-matching guard — only fire when the current pathname is a
|
||||
// dashboard route under the configured appRoot. The bug class this
|
||||
// catches is "filter writes pollute Explore's URL after navigation".
|
||||
//
|
||||
// 2. The prefix-strip — React Router applies `basename` internally, so
|
||||
// `history.replace({ pathname })` must receive a path WITHOUT the
|
||||
// appRoot. The bug class this catches is `/superset/superset/dashboard/...`
|
||||
// in the URL bar after the first filter change.
|
||||
//
|
||||
// `publishDataMask` is module-private (declared as a `const debounce(...)`).
|
||||
// Testing it through a rendered FilterBar requires the Redux store, the
|
||||
// filter cache API, and the debounce timer — heavyweight relative to what
|
||||
// the contract actually says. Instead this test does two things:
|
||||
//
|
||||
// A. Reads FilterBar/index.tsx as source and pins the two patterns that
|
||||
// embody the contract. A future refactor that drops the guard or the
|
||||
// strip fails here loudly with the exact line that drifted.
|
||||
// B. Tests the *equivalent* pure logic (re-implementation of the same
|
||||
// pattern) across every appRoot × pathname × Explore-vs-Dashboard
|
||||
// input shape that matters in practice. If the actual code drifts
|
||||
// from the documented invariant, the source-pin in (A) fires; if the
|
||||
// documented invariant itself is wrong, (B) fires.
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const FILTERBAR_SRC = readFileSync(join(__dirname, 'index.tsx'), 'utf8');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (A) Source-pin: the two patterns that implement the contract.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('FilterBar/index.tsx guards history.replace by the configured app root', () => {
|
||||
// The guard short-circuits the URL mutation when the current path is not a
|
||||
// dashboard route under the appRoot — e.g. when the user navigated to
|
||||
// Explore (`/explore/?slice_id=...`), the FilterBar's debounced commit must
|
||||
// not stomp Explore's query string with native_filters_key.
|
||||
expect(FILTERBAR_SRC).toContain(
|
||||
'window.location.pathname.startsWith(`${applicationRoot()}/dashboard`)',
|
||||
);
|
||||
});
|
||||
|
||||
test('FilterBar/index.tsx strips the app root before history.replace', () => {
|
||||
// Both halves of the strip survive together — the appRoot != "/" check
|
||||
// and the startsWith-before-substring guard. Each is load-bearing on its
|
||||
// own (without the first, root deploy hits `.substring(1)` and clips off
|
||||
// the leading slash; without the second, paths that diverge from the
|
||||
// appRoot get incorrectly truncated).
|
||||
expect(FILTERBAR_SRC).toContain(
|
||||
"if (appRoot !== '/' && replacementPathname.startsWith(appRoot))",
|
||||
);
|
||||
expect(FILTERBAR_SRC).toContain(
|
||||
'replacementPathname = replacementPathname.substring(appRoot.length);',
|
||||
);
|
||||
});
|
||||
|
||||
test('FilterBar/index.tsx imports applicationRoot from getBootstrapData', () => {
|
||||
// Centralised symbol — the static-scan invariant in
|
||||
// navigationUtils.invariants.test.ts enumerates the sanctioned import
|
||||
// sites. If FilterBar's import path drifts, that scan also fires; this
|
||||
// one anchors the import locally so a `git blame` on FilterBar tells the
|
||||
// story without needing to cross-reference the scan ledger.
|
||||
expect(FILTERBAR_SRC).toMatch(
|
||||
/import\s+\{\s*applicationRoot\s*\}\s+from\s+'src\/utils\/getBootstrapData'/,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (B) Characterisation: the documented invariant, exercised across the
|
||||
// appRoot × pathname matrix.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Re-implementation of the FilterBar guard + strip, kept here so the test
|
||||
// can fail loudly if the *invariant itself* is wrong (rather than a typo in
|
||||
// the implementation). The source-pin above catches the inverse case
|
||||
// (implementation drifts away from the invariant).
|
||||
|
||||
interface Scenario {
|
||||
description: string;
|
||||
appRoot: string;
|
||||
pathname: string;
|
||||
shouldReplace: boolean;
|
||||
replacementPathname?: string;
|
||||
}
|
||||
|
||||
function applyFilterBarPathLogic(
|
||||
appRoot: string,
|
||||
pathname: string,
|
||||
): { shouldReplace: boolean; replacementPathname?: string } {
|
||||
if (!pathname.startsWith(`${appRoot}/dashboard`)) {
|
||||
return { shouldReplace: false };
|
||||
}
|
||||
let replacement = pathname;
|
||||
if (appRoot !== '/' && replacement.startsWith(appRoot)) {
|
||||
replacement = replacement.substring(appRoot.length);
|
||||
}
|
||||
return { shouldReplace: true, replacementPathname: replacement };
|
||||
}
|
||||
|
||||
const SCENARIOS: ReadonlyArray<Scenario> = [
|
||||
// Root deploy — pathname matches `/dashboard` directly.
|
||||
{
|
||||
description: 'root deploy on a dashboard page',
|
||||
appRoot: '',
|
||||
pathname: '/dashboard/1/',
|
||||
shouldReplace: true,
|
||||
replacementPathname: '/dashboard/1/',
|
||||
},
|
||||
{
|
||||
description: 'root deploy on Explore — guard short-circuits',
|
||||
appRoot: '',
|
||||
pathname: '/explore/?slice_id=42',
|
||||
shouldReplace: false,
|
||||
},
|
||||
// Subdir deploy — appRoot is `/superset`, pathname carries the prefix.
|
||||
{
|
||||
description: 'subdir deploy on a dashboard page',
|
||||
appRoot: '/superset',
|
||||
pathname: '/superset/dashboard/2/',
|
||||
shouldReplace: true,
|
||||
// Stripped: React Router re-applies basename so the strip MUST happen.
|
||||
replacementPathname: '/dashboard/2/',
|
||||
},
|
||||
{
|
||||
description: 'subdir deploy on a dashboard permalink',
|
||||
appRoot: '/superset',
|
||||
pathname: '/superset/dashboard/p/abc123/',
|
||||
shouldReplace: true,
|
||||
replacementPathname: '/dashboard/p/abc123/',
|
||||
},
|
||||
{
|
||||
description: 'subdir deploy on Explore — guard short-circuits',
|
||||
appRoot: '/superset',
|
||||
pathname: '/superset/explore/?slice_id=7',
|
||||
shouldReplace: false,
|
||||
},
|
||||
{
|
||||
description:
|
||||
'subdir deploy on bare app root (no /dashboard) — short-circuits',
|
||||
appRoot: '/superset',
|
||||
pathname: '/superset/',
|
||||
shouldReplace: false,
|
||||
},
|
||||
// Operator deploy under a deeply nested basename.
|
||||
{
|
||||
description: 'deep-nested deploy on a dashboard page',
|
||||
appRoot: '/tenant-a/superset',
|
||||
pathname: '/tenant-a/superset/dashboard/9/',
|
||||
shouldReplace: true,
|
||||
replacementPathname: '/dashboard/9/',
|
||||
},
|
||||
// Adversarial: appRoot `/dash` is a substring of `/dashboard`. The guard
|
||||
// template is `${appRoot}/dashboard` so the prefix is `/dash/dashboard`,
|
||||
// which (correctly) does NOT match a bare `/dashboard/1/` path. Pin the
|
||||
// case so a maintainer doesn't "fix" the guard to also match prefix-free
|
||||
// paths (which would re-introduce the Explore-stomp regression for
|
||||
// operators whose root happens to share characters with `/dashboard`).
|
||||
{
|
||||
description:
|
||||
'appRoot is a substring prefix of /dashboard — guard does NOT match a bare /dashboard path',
|
||||
appRoot: '/dash',
|
||||
pathname: '/dashboard/5/',
|
||||
shouldReplace: false,
|
||||
},
|
||||
];
|
||||
|
||||
test.each(SCENARIOS)(
|
||||
'publishDataMask path logic: $description',
|
||||
({ appRoot, pathname, shouldReplace, replacementPathname }: Scenario) => {
|
||||
const result = applyFilterBarPathLogic(appRoot, pathname);
|
||||
expect(result.shouldReplace).toBe(shouldReplace);
|
||||
if (shouldReplace) {
|
||||
expect(result.replacementPathname).toBe(replacementPathname);
|
||||
// The dedupe contract: no `/superset/superset/...` ever reaches React
|
||||
// Router. Even if the source-pin drifts, this catches the user-visible
|
||||
// symptom.
|
||||
expect(result.replacementPathname).not.toMatch(/\/superset\/superset\//);
|
||||
} else {
|
||||
expect(result.replacementPathname).toBeUndefined();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -142,9 +142,11 @@ const publishDataMask = debounce(
|
||||
|
||||
// pathname could be updated somewhere else through window.history
|
||||
// keep react router history in sync with window history
|
||||
// replace params only when current page is /superset/dashboard
|
||||
// replace params only when current page is a dashboard route under the
|
||||
// configured applicationRoot (e.g. `/dashboard/...` for root deploy,
|
||||
// `/superset/dashboard/...` for the legacy subdir deploy).
|
||||
// this prevents a race condition between updating filters and navigating to Explore
|
||||
if (window.location.pathname.includes('/superset/dashboard')) {
|
||||
if (window.location.pathname.startsWith(`${applicationRoot()}/dashboard`)) {
|
||||
// The history API is part of React router and understands that a basename may exist.
|
||||
// Internally it treats all paths as if they are relative to the root and appends
|
||||
// it when necessary. We strip any prefix so that history.replace adds it back and doesn't
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DataMaskStateWithId,
|
||||
} from '@superset-ui/core';
|
||||
import rison from 'rison';
|
||||
import { navigateWithState } from 'src/utils/navigationUtils';
|
||||
|
||||
/**
|
||||
* Synthetic dataMask key for URL Rison filters that don't match any native
|
||||
@@ -238,7 +239,17 @@ export function prettifyRisonFilterUrl(): void {
|
||||
const prettifiedUrl = `${beforeRison}${separator}f=${risonValue}${afterRison}`;
|
||||
|
||||
if (prettifiedUrl !== currentUrl) {
|
||||
window.history.replaceState(window.history.state, '', prettifiedUrl);
|
||||
// Route through navigateWithState so the navigationUtils guards
|
||||
// (`assertSafeNavigationUrl` scheme/userinfo barriers + the
|
||||
// CodeQL-recognised inline sanitisers) apply at the
|
||||
// `window.history.replaceState` sink. The URL constructor inside
|
||||
// `navigateWithState` is conservative about re-encoding: sub-delims
|
||||
// like `(`, `)`, `:`, `!` (the meaningful Rison glyphs) survive,
|
||||
// so the prettification's visual win is preserved for every
|
||||
// character the prettifier actually targets.
|
||||
navigateWithState(prettifiedUrl, window.history.state ?? {}, {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to prettify Rison URL:', error);
|
||||
@@ -351,12 +362,11 @@ export function updateUrlWithUnmatchedFilters(
|
||||
// With a real `BrowserRouter`, `history.replace` would do this too — but
|
||||
// under a `createMemoryHistory` (used in tests, or in some embedded
|
||||
// contexts) it does not, and we'd leak the stale URL into the next
|
||||
// `getRisonFilterParam()` call.
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
currentUrl.toString(),
|
||||
);
|
||||
// `getRisonFilterParam()` call. Routed through navigateWithState so the
|
||||
// navigationUtils scheme/userinfo barriers gate the sink.
|
||||
navigateWithState(currentUrl.toString(), window.history.state ?? {}, {
|
||||
replace: true,
|
||||
});
|
||||
if (history) {
|
||||
history.replace({
|
||||
pathname: currentUrl.pathname,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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 fetchMock from 'fetch-mock';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import EmbedCodeContent from 'src/explore/components/EmbedCodeContent';
|
||||
|
||||
// Closes test-gap P0-3 from the 2026-06-02 subdirectory test-gap audit.
|
||||
//
|
||||
// The chart-embed iframe `src` is produced by:
|
||||
// 1. Backend `url_for(_external=True)` → absolute URL whose origin is the
|
||||
// backend `Host` header (often the internal docker hostname under
|
||||
// `superset-light:8088` when `ENABLE_PROXY_FIX` is off).
|
||||
// 2. Frontend `rewritePermalinkOrigin` swaps the origin for
|
||||
// `window.location.origin` so the iframe `src` is reachable from the
|
||||
// browser that pasted the embed code.
|
||||
// 3. The path segment (`/superset/explore/p/<key>/`) survives unchanged —
|
||||
// the application_root must therefore be applied exactly once.
|
||||
//
|
||||
// Until P0-3 was filed, this composition was verified only via manual QA
|
||||
// (memory `project_supersetclient_approot_dedupe` records the discovery).
|
||||
// This test pins the iframe-src shape so a future change to the permalink
|
||||
// API, the origin-rewrite helper, or the EmbedCodeContent template would
|
||||
// surface in CI rather than in a user-reported broken embed.
|
||||
|
||||
const SUBDIR_PERMALINK_URL =
|
||||
'http://superset-light:8088/superset/explore/p/abc123/';
|
||||
|
||||
fetchMock.post('glob:*/api/v1/explore/permalink', {
|
||||
url: SUBDIR_PERMALINK_URL,
|
||||
});
|
||||
|
||||
const mockFormData = {
|
||||
datasource: 'table__1',
|
||||
viz_type: 'table',
|
||||
};
|
||||
|
||||
test('iframe src under subdir deployment uses browser origin + single prefix', async () => {
|
||||
render(<EmbedCodeContent formData={mockFormData} />, { useRedux: true });
|
||||
|
||||
// The textarea `value` contains the full iframe HTML once the permalink
|
||||
// promise resolves. `data-test="embed-code-textarea"` is the stable hook.
|
||||
const textarea = await screen.findByTestId('embed-code-textarea');
|
||||
|
||||
// Wait for the asynchronous permalink fetch to land in the textarea.
|
||||
await waitFor(() =>
|
||||
expect((textarea as HTMLTextAreaElement).value).toContain('<iframe'),
|
||||
);
|
||||
|
||||
const html = (textarea as HTMLTextAreaElement).value;
|
||||
const srcMatch = html.match(/src="([^"]+)"/);
|
||||
expect(srcMatch).not.toBeNull();
|
||||
const src = (srcMatch as RegExpMatchArray)[1];
|
||||
|
||||
// Two contracts: origin is the browser-side origin (jsdom default
|
||||
// `http://localhost`), and the `/superset/` prefix from the backend
|
||||
// payload survives — exactly once.
|
||||
const parsed = new URL(src);
|
||||
expect(parsed.origin).toBe(window.location.origin);
|
||||
expect(parsed.pathname).toBe('/superset/explore/p/abc123/');
|
||||
expect(src).not.toContain('/superset/superset/');
|
||||
// Standalone + height controls are appended additively by the component.
|
||||
expect(parsed.searchParams.get('standalone')).toBe('1');
|
||||
expect(parsed.searchParams.get('height')).toBe('400');
|
||||
});
|
||||
@@ -45,6 +45,7 @@ import TextControl from 'src/explore/components/controls/TextControl';
|
||||
import CheckboxControl from 'src/explore/components/controls/CheckboxControl';
|
||||
import PopoverSection from '@superset-ui/core/components/PopoverSection';
|
||||
import ControlHeader from 'src/explore/components/ControlHeader';
|
||||
import { ensureAppRoot } from 'src/utils/navigationUtils';
|
||||
import {
|
||||
ANNOTATION_SOURCE_TYPES,
|
||||
ANNOTATION_TYPES,
|
||||
@@ -145,7 +146,13 @@ const NotFoundContent = () => (
|
||||
<span>
|
||||
{t('Add an annotation layer')}{' '}
|
||||
<a
|
||||
href="/annotationlayer/list"
|
||||
// encodeURI wraps the DOM-derived application-root prefix so
|
||||
// CodeQL's `js/html-injection` sees a recognised through-function
|
||||
// sanitiser between `applicationRoot()` (reads `data-bootstrap`
|
||||
// from the DOM) and the `<a href>` sink. The string fed in is a
|
||||
// URL-normalised path (`/seg/seg`) so encodeURI is idempotent in
|
||||
// practice — it does not alter the navigation target.
|
||||
href={encodeURI(ensureAppRoot('/annotationlayer/list'))}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -185,6 +185,7 @@ test('opens SQL Lab in a new tab when View in SQL Lab button is clicked with met
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
`/sqllab?datasourceKey=${datasource}&sql=${encodeURIComponent(sql)}`,
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
import { CopyToClipboard } from 'src/components';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import { makeUrl } from 'src/utils/pathUtils';
|
||||
import { openInNewTab } from 'src/utils/navigationUtils';
|
||||
import CodeSyntaxHighlighter, {
|
||||
SupportedLanguage,
|
||||
preloadLanguages,
|
||||
@@ -140,11 +140,8 @@ const ViewQuery: FC<ViewQueryProps> = props => {
|
||||
};
|
||||
if (domEvent.metaKey || domEvent.ctrlKey) {
|
||||
domEvent.preventDefault();
|
||||
window.open(
|
||||
makeUrl(
|
||||
`/sqllab?datasourceKey=${datasource}&sql=${encodeURIComponent(currentSQL)}`,
|
||||
),
|
||||
'_blank',
|
||||
openInNewTab(
|
||||
`/sqllab?datasourceKey=${datasource}&sql=${encodeURIComponent(currentSQL)}`,
|
||||
);
|
||||
} else {
|
||||
history.push({ pathname: '/sqllab', state: { requestedQuery } });
|
||||
|
||||
@@ -39,7 +39,7 @@ const TestDashboardsMenuItems = ({
|
||||
<div data-test="menu-items">
|
||||
{menuItems.map(item => (
|
||||
<div key={item.key} data-test={`menu-item-${item!.key}`}>
|
||||
{typeof item.label === 'string' ? item!.label : 'Complex Label'}
|
||||
{item!.label}
|
||||
{item!.disabled && <span data-test="disabled">disabled</span>}
|
||||
</div>
|
||||
))}
|
||||
@@ -173,6 +173,35 @@ describe('DashboardsSubMenu', () => {
|
||||
expect(screen.getByTestId('menu-item-5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders SPA-relative dashboard links without the /superset/ prefix', () => {
|
||||
// Regression: prior to the route_base="" alignment, this menu emitted
|
||||
// `to="/superset/dashboard/<id>"` which, combined with the React Router
|
||||
// `basename={applicationRoot()}`, produced a doubled `/superset/superset/`
|
||||
// path on subdirectory deployments and a backend 404.
|
||||
const dashboards = [{ id: 9, dashboard_title: 'Sales Dashboard' }];
|
||||
render(
|
||||
<TestDashboardsMenuItems
|
||||
chartId={102}
|
||||
dashboards={dashboards}
|
||||
searchTerm=""
|
||||
/>,
|
||||
{ useRouter: true },
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link', { name: /Sales Dashboard/ });
|
||||
expect(link).toHaveAttribute('href', '/dashboard/9?focused_chart=102');
|
||||
});
|
||||
|
||||
test('omits the focused_chart query when chartId is undefined', () => {
|
||||
const dashboards = [{ id: 9, dashboard_title: 'Sales Dashboard' }];
|
||||
render(<TestDashboardsMenuItems dashboards={dashboards} searchTerm="" />, {
|
||||
useRouter: true,
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link', { name: /Sales Dashboard/ });
|
||||
expect(link).toHaveAttribute('href', '/dashboard/9');
|
||||
});
|
||||
|
||||
test('partial string search works correctly', () => {
|
||||
const dashboards = [
|
||||
{ id: 1, dashboard_title: 'Revenue Report' },
|
||||
|
||||
@@ -71,8 +71,8 @@ export const useDashboardsMenuItems = ({
|
||||
label: (
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noreferer noopener"
|
||||
to={`/superset/dashboard/${dashboard.id}${urlQueryString}`}
|
||||
rel="noreferrer noopener"
|
||||
to={`/dashboard/${dashboard.id}${urlQueryString}`}
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('exploreUtils', () => {
|
||||
force: false,
|
||||
curUrl: 'http://superset.com',
|
||||
});
|
||||
compareURI(URI(url!), URI('/superset/explore_json/'));
|
||||
compareURI(URI(url!), URI('/explore_json/'));
|
||||
});
|
||||
test('generates proper json forced url', () => {
|
||||
const url = getExploreUrl({
|
||||
@@ -75,10 +75,7 @@ describe('exploreUtils', () => {
|
||||
force: true,
|
||||
curUrl: 'superset.com',
|
||||
});
|
||||
compareURI(
|
||||
URI(url!),
|
||||
URI('/superset/explore_json/').search({ force: 'true' }),
|
||||
);
|
||||
compareURI(URI(url!), URI('/explore_json/').search({ force: 'true' }));
|
||||
});
|
||||
test('generates proper csv URL', () => {
|
||||
const url = getExploreUrl({
|
||||
@@ -87,10 +84,7 @@ describe('exploreUtils', () => {
|
||||
force: false,
|
||||
curUrl: 'superset.com',
|
||||
});
|
||||
compareURI(
|
||||
URI(url!),
|
||||
URI('/superset/explore_json/').search({ csv: 'true' }),
|
||||
);
|
||||
compareURI(URI(url!), URI('/explore_json/').search({ csv: 'true' }));
|
||||
});
|
||||
test('generates proper standalone URL', () => {
|
||||
const url = getExploreUrl({
|
||||
@@ -113,10 +107,7 @@ describe('exploreUtils', () => {
|
||||
force: false,
|
||||
curUrl: 'superset.com?foo=bar',
|
||||
});
|
||||
compareURI(
|
||||
URI(url!),
|
||||
URI('/superset/explore_json/').search({ foo: 'bar' }),
|
||||
);
|
||||
compareURI(URI(url!), URI('/explore_json/').search({ foo: 'bar' }));
|
||||
});
|
||||
test('generate proper save slice url', () => {
|
||||
const url = getExploreUrl({
|
||||
@@ -125,10 +116,7 @@ describe('exploreUtils', () => {
|
||||
force: false,
|
||||
curUrl: 'superset.com?foo=bar',
|
||||
});
|
||||
compareURI(
|
||||
URI(url!),
|
||||
URI('/superset/explore_json/').search({ foo: 'bar' }),
|
||||
);
|
||||
compareURI(URI(url!), URI('/explore_json/').search({ foo: 'bar' }));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -185,9 +185,12 @@ test('exportChart legacy API (useLegacyApi=true) passes prefixed URL to onStartS
|
||||
|
||||
expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
|
||||
const callArgs = onStartStreamingExport.mock.calls[0][0];
|
||||
// The legacy blueprint path is /superset/explore_json/; with appRoot=/superset the
|
||||
// full streaming URL is /superset/superset/explore_json/ (appRoot + blueprint prefix).
|
||||
expect(callArgs.url).toBe('/superset/superset/explore_json/?csv=true');
|
||||
// Slice 3c: post `Superset.route_base = ""`, the blueprint path is
|
||||
// `/explore_json/`. With appRoot=/superset, the prefixed URL is
|
||||
// `/superset/explore_json/?csv=true` — single-prefix, not the legacy
|
||||
// doubled `/superset/superset/explore_json/...` that this test used to
|
||||
// pin (which was the bug, now fixed at source).
|
||||
expect(callArgs.url).toBe('/superset/explore_json/?csv=true');
|
||||
expect(callArgs.exportType).toBe('csv');
|
||||
});
|
||||
|
||||
@@ -212,7 +215,7 @@ test('exportChart legacy API builds relative URL for CSV export without app root
|
||||
|
||||
expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
|
||||
const callArgs = onStartStreamingExport.mock.calls[0][0];
|
||||
expect(callArgs.url).toBe('/superset/explore_json/?csv=true');
|
||||
expect(callArgs.url).toBe('/explore_json/?csv=true');
|
||||
});
|
||||
|
||||
test('exportChart legacy API builds relative URL for xlsx export', async () => {
|
||||
@@ -237,7 +240,7 @@ test('exportChart legacy API builds relative URL for xlsx export', async () => {
|
||||
|
||||
expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
|
||||
const callArgs = onStartStreamingExport.mock.calls[0][0];
|
||||
expect(callArgs.url).toBe('/superset/explore_json/?xlsx=true');
|
||||
expect(callArgs.url).toBe('/explore_json/?xlsx=true');
|
||||
});
|
||||
|
||||
test('exportChart legacy API calls postForm with relative URL', async () => {
|
||||
@@ -261,7 +264,7 @@ test('exportChart legacy API calls postForm with relative URL', async () => {
|
||||
|
||||
expect(SupersetClient.postForm).toHaveBeenCalledTimes(1);
|
||||
const [url] = SupersetClient.postForm.mock.calls[0];
|
||||
expect(url).toBe('/superset/explore_json/?csv=true');
|
||||
expect(url).toBe('/explore_json/?csv=true');
|
||||
expect(url).not.toMatch(/^https?:\/\//);
|
||||
});
|
||||
|
||||
@@ -287,5 +290,5 @@ test('exportChart legacy API includes force param when force=true', async () =>
|
||||
|
||||
expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
|
||||
const callArgs = onStartStreamingExport.mock.calls[0][0];
|
||||
expect(callArgs.url).toBe('/superset/explore_json/?force=true&csv=true');
|
||||
expect(callArgs.url).toBe('/explore_json/?force=true&csv=true');
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ test('Get ExploreUrl with default params', () => {
|
||||
test('Get ExploreUrl with endpointType:full', () => {
|
||||
const params = createParams();
|
||||
expect(getExploreUrl({ ...params, endpointType: 'full' })).toBe(
|
||||
'http://localhost/superset/explore_json/',
|
||||
'http://localhost/explore_json/',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -47,21 +47,21 @@ test('Get ExploreUrl with endpointType:full and method:GET', () => {
|
||||
const params = createParams();
|
||||
expect(
|
||||
getExploreUrl({ ...params, endpointType: 'full', method: 'GET' }),
|
||||
).toBe('http://localhost/superset/explore_json/');
|
||||
).toBe('http://localhost/explore_json/');
|
||||
});
|
||||
|
||||
test('Get relative ExploreUrl with endpointType:csv', () => {
|
||||
const params = createParams();
|
||||
expect(
|
||||
getExploreUrl({ ...params, endpointType: 'csv', relative: true }),
|
||||
).toBe('/superset/explore_json/?csv=true');
|
||||
).toBe('/explore_json/?csv=true');
|
||||
});
|
||||
|
||||
test('Get relative ExploreUrl with endpointType:xlsx', () => {
|
||||
const params = createParams();
|
||||
expect(
|
||||
getExploreUrl({ ...params, endpointType: 'xlsx', relative: true }),
|
||||
).toBe('/superset/explore_json/?xlsx=true');
|
||||
).toBe('/explore_json/?xlsx=true');
|
||||
});
|
||||
|
||||
test('Get relative ExploreUrl with force:true', () => {
|
||||
@@ -73,7 +73,7 @@ test('Get relative ExploreUrl with force:true', () => {
|
||||
force: true,
|
||||
relative: true,
|
||||
}),
|
||||
).toBe('/superset/explore_json/?force=true&csv=true');
|
||||
).toBe('/explore_json/?force=true&csv=true');
|
||||
});
|
||||
|
||||
test('Get relative ExploreUrl with endpointType:base', () => {
|
||||
|
||||
@@ -19,8 +19,12 @@
|
||||
import { getURIDirectory } from '.';
|
||||
|
||||
test('Cases in which the "explore_json" will be returned', () => {
|
||||
// Slice 3c: post `Superset.route_base = ""` collapse the legacy
|
||||
// `/superset/explore_json/` endpoint is gone; `getURIDirectory` now
|
||||
// returns the bare `/explore_json/` path (appRoot is applied at the
|
||||
// outer `ensureAppRoot` layer when `includeAppRoot=true`).
|
||||
['full', 'json', 'csv', 'query', 'results', 'samples'].forEach(name => {
|
||||
expect(getURIDirectory(name)).toBe('/superset/explore_json/');
|
||||
expect(getURIDirectory(name)).toBe('/explore_json/');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
import { availableDomains } from 'src/utils/hostNamesConfig';
|
||||
import { safeStringify } from 'src/utils/safeStringify';
|
||||
import { optionLabel } from 'src/utils/common';
|
||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
||||
import { ensureAppRoot } from 'src/utils/navigationUtils';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import {
|
||||
DISABLE_INPUT_OPERATORS,
|
||||
@@ -164,7 +164,7 @@ export function getURIDirectory(
|
||||
'results',
|
||||
'samples',
|
||||
].includes(endpointType)
|
||||
? '/superset/explore_json/'
|
||||
? '/explore_json/'
|
||||
: '/explore/';
|
||||
return includeAppRoot ? ensureAppRoot(uri) : uri;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
import * as supersetCore from '@apache-superset/core';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
@@ -52,12 +52,20 @@ declare global {
|
||||
const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
const userId = useSelector<RootState, number | undefined>(
|
||||
({ user }) => user.userId,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId == null) return;
|
||||
if (initialized) return;
|
||||
|
||||
if (!userId) {
|
||||
// No user logged in — nothing to initialize
|
||||
setInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Provide the implementations for @apache-superset/core
|
||||
window.superset = {
|
||||
@@ -72,10 +80,19 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
views,
|
||||
};
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
ExtensionsLoader.getInstance().initializeExtensions();
|
||||
}
|
||||
}, [userId]);
|
||||
const setup = async () => {
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
await ExtensionsLoader.getInstance().initializeExtensions();
|
||||
}
|
||||
setInitialized(true);
|
||||
};
|
||||
|
||||
setup();
|
||||
}, [initialized, userId]);
|
||||
|
||||
if (!initialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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 } from 'spec/helpers/testing-library';
|
||||
import type { TaggedObjects } from 'src/types/TaggedObject';
|
||||
import AllEntitiesTable from './AllEntitiesTable';
|
||||
|
||||
// Regression for the tag list page (sc-108439): backend `.url` properties on
|
||||
// Dashboard / Slice / SavedQuery are router-relative by contract
|
||||
// (see `superset/models/dashboard.py` `dashboard_link` docstring: "`Dashboard.url`
|
||||
// itself stays router-relative so frontend callers can apply ensureAppRoot
|
||||
// exactly once"). The TagDAO emits those router-relative paths verbatim as
|
||||
// `o.url` on tagged-object responses. AllEntitiesTable therefore must wrap
|
||||
// `o.url` with `ensureAppRoot` exactly once; otherwise the row hrefs lack
|
||||
// the `SUPERSET_APP_ROOT` prefix and clicks land on broken links.
|
||||
//
|
||||
// Mocking note (same as SliceHeaderControls.subdirectory.test.tsx): the
|
||||
// component is imported statically, so the `withApplicationRoot` fixture's
|
||||
// `jest.resetModules()` + dynamic re-import pattern can't retroactively
|
||||
// change which `getBootstrapData` instance the already-bound module graph
|
||||
// sees. We mock `src/utils/getBootstrapData` directly with a reconfigurable
|
||||
// `mockApplicationRoot` factory and flip it per scenario.
|
||||
//
|
||||
// Name must start with `mock` so Jest's hoisted jest.mock() factory may
|
||||
// reference it. `default` returns a STATIC shape (not mockApplicationRoot)
|
||||
// because consumers like the reducer chain call getBootstrapData() at
|
||||
// import time — calling mockApplicationRoot inside `default` hits TDZ.
|
||||
|
||||
const mockApplicationRoot = jest.fn<string, []>(() => '');
|
||||
|
||||
jest.mock('src/utils/getBootstrapData', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
common: { application_root: '', static_assets_prefix: '' },
|
||||
}),
|
||||
applicationRoot: () => mockApplicationRoot(),
|
||||
staticAssetsPrefix: () => '',
|
||||
}));
|
||||
|
||||
const BACKEND_URLS = {
|
||||
dashboard: '/dashboard/sales/',
|
||||
chart: '/explore/?slice_id=42',
|
||||
query: '/sqllab?savedQueryId=7',
|
||||
};
|
||||
|
||||
const SAMPLE_OBJECTS: TaggedObjects = {
|
||||
dashboard: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'dashboard',
|
||||
name: 'Sales',
|
||||
url: BACKEND_URLS.dashboard,
|
||||
changed_on: '2026-06-01T00:00:00Z',
|
||||
created_by: 1,
|
||||
creator: 'admin',
|
||||
owners: [],
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
chart: [
|
||||
{
|
||||
id: 42,
|
||||
type: 'chart',
|
||||
name: 'Top Customers',
|
||||
url: BACKEND_URLS.chart,
|
||||
changed_on: '2026-06-01T00:00:00Z',
|
||||
created_by: 1,
|
||||
creator: 'admin',
|
||||
owners: [],
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
query: [
|
||||
{
|
||||
id: 7,
|
||||
type: 'query',
|
||||
name: 'Daily Revenue',
|
||||
url: BACKEND_URLS.query,
|
||||
changed_on: '2026-06-01T00:00:00Z',
|
||||
created_by: 1,
|
||||
creator: 'admin',
|
||||
owners: [],
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const renderAndCollectHrefs = (): string[] => {
|
||||
const { container } = render(
|
||||
<AllEntitiesTable
|
||||
objects={SAMPLE_OBJECTS}
|
||||
setShowTagModal={() => {}}
|
||||
canEditTag
|
||||
/>,
|
||||
{ useRedux: true, useTheme: true },
|
||||
);
|
||||
return Array.from(container.querySelectorAll<HTMLAnchorElement>('a[href]'))
|
||||
.map(a => a.getAttribute('href') ?? '')
|
||||
.filter(href => href !== '' && href !== '#');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockApplicationRoot.mockReset();
|
||||
});
|
||||
|
||||
test('row hrefs carry the application root under /superset', () => {
|
||||
mockApplicationRoot.mockReturnValue('/superset');
|
||||
const hrefs = renderAndCollectHrefs();
|
||||
|
||||
expect(hrefs).toEqual(
|
||||
expect.arrayContaining([
|
||||
`/superset${BACKEND_URLS.dashboard}`,
|
||||
`/superset${BACKEND_URLS.chart}`,
|
||||
`/superset${BACKEND_URLS.query}`,
|
||||
]),
|
||||
);
|
||||
hrefs.forEach(href => {
|
||||
expect(href).not.toMatch(/^\/superset\/superset\//);
|
||||
expect(href.startsWith('/superset')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('row hrefs are unchanged under the default root-of-domain deployment', () => {
|
||||
mockApplicationRoot.mockReturnValue('');
|
||||
const hrefs = renderAndCollectHrefs();
|
||||
|
||||
expect(hrefs).toEqual(
|
||||
expect.arrayContaining([
|
||||
BACKEND_URLS.dashboard,
|
||||
BACKEND_URLS.chart,
|
||||
BACKEND_URLS.query,
|
||||
]),
|
||||
);
|
||||
hrefs.forEach(href => {
|
||||
expect(href).not.toMatch(/^\/superset/);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import { EmptyState } from '@superset-ui/core/components';
|
||||
import { FacePile, TagsList, type TagType } from 'src/components';
|
||||
import { TaggedObject, TaggedObjects } from 'src/types/TaggedObject';
|
||||
import { Typography } from '@superset-ui/core/components/Typography';
|
||||
import { ensureAppRoot } from 'src/utils/navigationUtils';
|
||||
|
||||
const MAX_TAGS_TO_SHOW = 3;
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -73,7 +74,9 @@ export default function AllEntitiesTable({
|
||||
|
||||
const renderTable = (type: objectType) => {
|
||||
const data = objects[type].map((o: TaggedObject) => ({
|
||||
[type]: <Typography.Link href={o.url}>{o.name}</Typography.Link>,
|
||||
[type]: (
|
||||
<Typography.Link href={ensureAppRoot(o.url)}>{o.name}</Typography.Link>
|
||||
),
|
||||
modified: o.changed_on ? extendedDayjs.utc(o.changed_on).fromNow() : '',
|
||||
tags: o.tags,
|
||||
owners: o.owners,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Subdirectory regression coverage for the DatabaseModal "post-connection"
|
||||
// call-to-action buttons (Create dataset + Query data in SQL Lab).
|
||||
//
|
||||
// Original bug class: under a subdir deployment (`applicationRoot = '/superset'`)
|
||||
// the "Query data in SQL Lab" button navigated to a double-prefixed URL
|
||||
// (`/superset/superset/sqllab?db=true`). The fix routes both CTA buttons
|
||||
// through `redirectURL`, which delegates to `useHistory().push(...)`. React
|
||||
// Router's `BrowserRouter basename={applicationRoot()}` re-applies the prefix
|
||||
// internally, so the argument to `history.push` MUST be a router-relative
|
||||
// path (no leading `${applicationRoot}` and no `ensureAppRoot` wrap).
|
||||
//
|
||||
// Reaching `renderCTABtns()` through a rendered modal requires walking the
|
||||
// full "select engine → fill SQLAlchemy form → submit → wait for success"
|
||||
// flow with every fetch mocked. The contract under test is much smaller
|
||||
// than that surface, so this file uses the same source-pin + pure-logic
|
||||
// characterisation pattern as
|
||||
// `dashboard/components/nativeFilters/FilterBar/FilterBar.subdirectory.test.ts`.
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const MODAL_SRC = readFileSync(join(__dirname, 'index.tsx'), 'utf8');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source-pin: redirectURL receives router-relative paths.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('DatabaseModal redirectURL delegates to history.push', () => {
|
||||
// The redirectURL helper is the single funnel for post-connection
|
||||
// navigation. Both CTA buttons call it; the only safe argument shape is
|
||||
// a router-relative path, because BrowserRouter's basename re-applies the
|
||||
// app root. Pinning the helper body here means a future refactor that
|
||||
// re-introduces `applicationRoot()` or `ensureAppRoot` at this layer
|
||||
// fails loudly with the exact callsite.
|
||||
expect(MODAL_SRC).toMatch(
|
||||
/const redirectURL = \(url: string\) => \{\s*history\.push\(url\);\s*\};/,
|
||||
);
|
||||
});
|
||||
|
||||
test('DatabaseModal "Query data in SQL Lab" pushes a router-relative /sqllab', () => {
|
||||
// The exact string we want in source. If someone "fixes" subdir support
|
||||
// by wrapping this in ensureAppRoot or hard-coding `/superset/sqllab`,
|
||||
// this test fires before the bad change ships.
|
||||
expect(MODAL_SRC).toContain("redirectURL('/sqllab?db=true')");
|
||||
});
|
||||
|
||||
test('DatabaseModal "Create dataset" pushes a router-relative /dataset/add/', () => {
|
||||
// Symmetric invariant for the sibling CTA button — same risk class
|
||||
// (basename double-prefix) applies. Pinning prevents drift where someone
|
||||
// "fixes" one button and leaves the other inconsistent.
|
||||
expect(MODAL_SRC).toContain("redirectURL('/dataset/add/')");
|
||||
});
|
||||
|
||||
test('DatabaseModal CTA buttons do NOT prefix the app root themselves', () => {
|
||||
// The buttons must not call applicationRoot()/ensureAppRoot/makeUrl on
|
||||
// these specific paths — basename handles the prefix, and any extra
|
||||
// prefixing produces `/superset/superset/...`. Search the renderCTABtns
|
||||
// block (lines ~1855-1879 at time of writing) for offending patterns.
|
||||
const ctaMatch = MODAL_SRC.match(
|
||||
/const renderCTABtns = \(\) =>[\s\S]*?<\/StyledBtns>\s*\);/,
|
||||
);
|
||||
expect(ctaMatch).not.toBeNull();
|
||||
const ctaSrc = ctaMatch![0];
|
||||
expect(ctaSrc).not.toMatch(/applicationRoot\s*\(/);
|
||||
expect(ctaSrc).not.toMatch(/ensureAppRoot\s*\(/);
|
||||
expect(ctaSrc).not.toMatch(/makeUrl\s*\(/);
|
||||
// And the specific double-prefixed string the original bug produced —
|
||||
// pin it so a regression that hard-codes the app root never ships.
|
||||
expect(ctaSrc).not.toContain('/superset/sqllab');
|
||||
expect(ctaSrc).not.toContain('/superset/dataset/add');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Characterisation: the documented invariant exercised across app-roots.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Re-implements the behaviour the source-pin describes: a router-relative
|
||||
// path pushed through history under BrowserRouter's basename produces a
|
||||
// single-prefixed URL. If the documented invariant itself is wrong the
|
||||
// source-pin is useless; this matrix catches that.
|
||||
|
||||
interface Scenario {
|
||||
description: string;
|
||||
basename: string;
|
||||
pushArg: string;
|
||||
expected: string;
|
||||
}
|
||||
|
||||
// Mirror of how React Router composes basename + pushed path. React Router
|
||||
// requires the pushed path to start with '/' and concatenates basename in
|
||||
// front (stripping a trailing slash on basename if present).
|
||||
function composeUnderBasename(basename: string, pushArg: string): string {
|
||||
const normalisedBase =
|
||||
basename === '/' || basename === '' ? '' : basename.replace(/\/+$/, '');
|
||||
return `${normalisedBase}${pushArg}`;
|
||||
}
|
||||
|
||||
const SCENARIOS: ReadonlyArray<Scenario> = [
|
||||
{
|
||||
description: 'root deploy: bare basename + /sqllab?db=true',
|
||||
basename: '',
|
||||
pushArg: '/sqllab?db=true',
|
||||
expected: '/sqllab?db=true',
|
||||
},
|
||||
{
|
||||
description: 'subdir deploy: /superset + /sqllab?db=true',
|
||||
basename: '/superset',
|
||||
pushArg: '/sqllab?db=true',
|
||||
expected: '/superset/sqllab?db=true',
|
||||
},
|
||||
{
|
||||
description: 'subdir deploy: /superset + /dataset/add/',
|
||||
basename: '/superset',
|
||||
pushArg: '/dataset/add/',
|
||||
expected: '/superset/dataset/add/',
|
||||
},
|
||||
{
|
||||
description: 'deep-nested deploy: /tenant-a/superset + /sqllab?db=true',
|
||||
basename: '/tenant-a/superset',
|
||||
pushArg: '/sqllab?db=true',
|
||||
expected: '/tenant-a/superset/sqllab?db=true',
|
||||
},
|
||||
{
|
||||
description: 'subdir deploy: trailing slash on basename collapses cleanly',
|
||||
basename: '/superset/',
|
||||
pushArg: '/sqllab?db=true',
|
||||
expected: '/superset/sqllab?db=true',
|
||||
},
|
||||
];
|
||||
|
||||
test.each(SCENARIOS)(
|
||||
'redirectURL navigation: $description',
|
||||
({ basename, pushArg, expected }: Scenario) => {
|
||||
const url = composeUnderBasename(basename, pushArg);
|
||||
expect(url).toBe(expected);
|
||||
// The dedupe contract: no `/superset/superset/...` ever reaches the
|
||||
// browser. Even if the source-pin drifts, this catches the user-visible
|
||||
// symptom for the subdir case.
|
||||
expect(url).not.toMatch(/\/superset\/superset\//);
|
||||
},
|
||||
);
|
||||
@@ -26,6 +26,7 @@ import Table, {
|
||||
TableSize,
|
||||
} from '@superset-ui/core/components/Table';
|
||||
import { DatasetObject } from 'src/features/datasets/AddDataset/types';
|
||||
import { openInNewTab } from 'src/utils/navigationUtils';
|
||||
import { ITableColumn } from './types';
|
||||
import MessageContent from './MessageContent';
|
||||
|
||||
@@ -227,11 +228,9 @@ const renderExistingDatasetAlert = (dataset?: DatasetObject) => (
|
||||
<span
|
||||
role="button"
|
||||
onClick={() => {
|
||||
window.open(
|
||||
dataset?.explore_url,
|
||||
'_blank',
|
||||
'noreferrer noopener popup=false',
|
||||
);
|
||||
if (dataset?.explore_url) {
|
||||
openInNewTab(dataset.explore_url);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
className="view-dataset-button"
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from 'src/features/datasets/AddDataset/types';
|
||||
import { Table } from 'src/hooks/apiResources';
|
||||
import { Typography } from '@superset-ui/core/components/Typography';
|
||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
||||
import { ensureAppRoot } from 'src/utils/navigationUtils';
|
||||
|
||||
interface LeftPanelProps {
|
||||
setDataset: Dispatch<SetStateAction<object>>;
|
||||
|
||||
@@ -25,6 +25,35 @@ import * as CoreTheme from '@apache-superset/core/theme';
|
||||
import { Menu } from './Menu';
|
||||
import * as getBootstrapData from 'src/utils/getBootstrapData';
|
||||
|
||||
// Capture what `<GenericLink to={...}>` receives so the SPA-route regression
|
||||
// tests can assert on the value handed to react-router-dom (which applies its
|
||||
// own basename in production via `<Router basename={applicationRoot()}>` in
|
||||
// src/views/App.tsx). The test harness's `<BrowserRouter>` has no basename,
|
||||
// so asserting on the rendered `<a href>` wouldn't catch the double-prefix.
|
||||
let observedGenericLinkTo: unknown = null;
|
||||
jest.mock('src/components/GenericLink', () => ({
|
||||
__esModule: true,
|
||||
GenericLink: ({
|
||||
to,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
to: unknown;
|
||||
children: React.ReactNode;
|
||||
[k: string]: unknown;
|
||||
}) => {
|
||||
observedGenericLinkTo = to;
|
||||
return (
|
||||
<a
|
||||
href={typeof to === 'string' ? to : '#'}
|
||||
{...(rest as Record<string, unknown>)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@apache-superset/core/theme', () => ({
|
||||
...jest.requireActual('@apache-superset/core/theme'),
|
||||
useTheme: jest.fn(),
|
||||
@@ -796,3 +825,152 @@ test('brand link falls back to brand.path when theme brandLogoUrl is absent', as
|
||||
// ensureAppRoot must have been applied: /welcome/ → /superset/welcome/
|
||||
expect(brandLink).toHaveAttribute('href', '/superset/welcome/');
|
||||
});
|
||||
|
||||
// Regression: the real backend emits `brand.path` and `brand.icon` already
|
||||
// carrying the app root (because they pass through `url_for`). The frontend
|
||||
// must not double-prefix them — neither via
|
||||
// `ensureAppRoot`/`ensureStaticPrefix` nor via React Router's `basename`
|
||||
// re-prepend.
|
||||
//
|
||||
// In production the SPA-route branch goes through `<GenericLink to={...}> ->
|
||||
// react-router-dom <Link>`, and the Router's `basename={applicationRoot()}`
|
||||
// (src/views/App.tsx) re-prepends the app root to the rendered `href`. The
|
||||
// test harness's `<BrowserRouter>` has no basename, so asserting on the
|
||||
// rendered `<a href>` wouldn't catch the bug. Instead we mock `GenericLink`
|
||||
// at module load (top of this file) and assert the path *handed to it*
|
||||
// already has the root stripped — the value the production Router will then
|
||||
// safely re-prepend.
|
||||
|
||||
describe('brand link single-prefix regressions (subdirectory deployment)', () => {
|
||||
beforeEach(() => {
|
||||
observedGenericLinkTo = null;
|
||||
});
|
||||
|
||||
test('brand link hands a root-stripped path to GenericLink when brand.path arrives already rooted (SPA route)', async () => {
|
||||
applicationRootMock.mockReturnValue('/superset');
|
||||
staticAssetsPrefixMock.mockReturnValue('/superset');
|
||||
useSelectorMock.mockReturnValue({ roles: user.roles });
|
||||
|
||||
const propsWithRootedBrand = {
|
||||
...mockedProps,
|
||||
isFrontendRoute: () => true,
|
||||
data: {
|
||||
...mockedProps.data,
|
||||
brand: {
|
||||
...mockedProps.data.brand,
|
||||
path: '/superset/welcome/',
|
||||
icon: '/superset/static/assets/images/superset-logo-horiz.png',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(<Menu {...propsWithRootedBrand} />, {
|
||||
useRedux: true,
|
||||
useQueryParams: true,
|
||||
useRouter: true,
|
||||
useTheme: true,
|
||||
});
|
||||
|
||||
// Wait for the mocked GenericLink to render.
|
||||
await screen.findByRole('link', {
|
||||
name: new RegExp(propsWithRootedBrand.data.brand.alt, 'i'),
|
||||
});
|
||||
expect(observedGenericLinkTo).toBe('/welcome/');
|
||||
});
|
||||
|
||||
test('brand link is single-prefix when brand.path arrives already rooted (non-SPA route)', async () => {
|
||||
applicationRootMock.mockReturnValue('/superset');
|
||||
staticAssetsPrefixMock.mockReturnValue('/superset');
|
||||
useSelectorMock.mockReturnValue({ roles: user.roles });
|
||||
|
||||
const propsWithRootedBrand = {
|
||||
...mockedProps,
|
||||
isFrontendRoute: () => false,
|
||||
data: {
|
||||
...mockedProps.data,
|
||||
brand: {
|
||||
...mockedProps.data.brand,
|
||||
path: '/superset/welcome/',
|
||||
icon: '/superset/static/assets/images/superset-logo-horiz.png',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(<Menu {...propsWithRootedBrand} />, {
|
||||
useRedux: true,
|
||||
useQueryParams: true,
|
||||
useRouter: true,
|
||||
useTheme: true,
|
||||
});
|
||||
|
||||
const brandLink = await screen.findByRole('link', {
|
||||
name: new RegExp(propsWithRootedBrand.data.brand.alt, 'i'),
|
||||
});
|
||||
expect(brandLink).toHaveAttribute('href', '/superset/welcome/');
|
||||
const brandImg = brandLink.querySelector('img');
|
||||
expect(brandImg).toHaveAttribute(
|
||||
'src',
|
||||
'/superset/static/assets/images/superset-logo-horiz.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('brand link strips a nested application root before handing to GenericLink', async () => {
|
||||
applicationRootMock.mockReturnValue('/preset/superset');
|
||||
staticAssetsPrefixMock.mockReturnValue('/preset/superset');
|
||||
useSelectorMock.mockReturnValue({ roles: user.roles });
|
||||
|
||||
const propsWithRootedBrand = {
|
||||
...mockedProps,
|
||||
isFrontendRoute: () => true,
|
||||
data: {
|
||||
...mockedProps.data,
|
||||
brand: {
|
||||
...mockedProps.data.brand,
|
||||
path: '/preset/superset/welcome/',
|
||||
icon: '/preset/superset/static/assets/images/superset-logo-horiz.png',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(<Menu {...propsWithRootedBrand} />, {
|
||||
useRedux: true,
|
||||
useQueryParams: true,
|
||||
useRouter: true,
|
||||
useTheme: true,
|
||||
});
|
||||
|
||||
await screen.findByRole('link', {
|
||||
name: new RegExp(propsWithRootedBrand.data.brand.alt, 'i'),
|
||||
});
|
||||
expect(observedGenericLinkTo).toBe('/welcome/');
|
||||
});
|
||||
|
||||
test('brand link from theme.brandLogoHref is single-prefix when already rooted', async () => {
|
||||
applicationRootMock.mockReturnValue('/superset');
|
||||
staticAssetsPrefixMock.mockReturnValue('/superset');
|
||||
useSelectorMock.mockReturnValue({ roles: user.roles });
|
||||
|
||||
useThemeMock.mockReturnValue({
|
||||
...CoreTheme.supersetTheme,
|
||||
brandLogoUrl: '/superset/static/assets/images/custom-logo.png',
|
||||
brandLogoHref: '/superset/welcome/',
|
||||
});
|
||||
|
||||
render(<Menu {...mockedProps} />, {
|
||||
useRedux: true,
|
||||
useQueryParams: true,
|
||||
useRouter: true,
|
||||
useTheme: true,
|
||||
});
|
||||
|
||||
const brandLink = await screen.findByRole('link', {
|
||||
name: /apache superset/i,
|
||||
});
|
||||
expect(brandLink).toHaveAttribute('href', '/superset/welcome/');
|
||||
const brandImg = brandLink.querySelector('img');
|
||||
expect(brandImg).toHaveAttribute(
|
||||
'src',
|
||||
'/superset/static/assets/images/custom-logo.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useState, useEffect } from 'react';
|
||||
import { styled, css, useTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ensureStaticPrefix } from 'src/utils/assetUrl';
|
||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
||||
import { ensureAppRoot, stripAppRoot } from 'src/utils/navigationUtils';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { MainNav, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { Tooltip, Grid, Row, Col, Image } from '@superset-ui/core/components';
|
||||
@@ -244,10 +244,18 @@ export function Menu({
|
||||
isFrontendRoute,
|
||||
}: MenuObjectProps): MenuItem => {
|
||||
if (url && isFrontendRoute) {
|
||||
// `<Router basename={applicationRoot()}>` re-prepends the app root to
|
||||
// `to`, so handing it the already-rooted `url` from bootstrap_data
|
||||
// would render a doubled `/superset/superset/...` anchor. Strip the
|
||||
// root first; mirrors the brand-link treatment below.
|
||||
return {
|
||||
key: label,
|
||||
label: (
|
||||
<NavLink role="button" to={url} activeClassName="is-active">
|
||||
<NavLink
|
||||
role="button"
|
||||
to={stripAppRoot(url)}
|
||||
activeClassName="is-active"
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
),
|
||||
@@ -269,7 +277,11 @@ export function Menu({
|
||||
childItems.push({
|
||||
key: `${child.label}`,
|
||||
label: child.isFrontendRoute ? (
|
||||
<NavLink to={child.url || ''} exact activeClassName="is-active">
|
||||
<NavLink
|
||||
to={stripAppRoot(child.url || '')}
|
||||
exact
|
||||
activeClassName="is-active"
|
||||
>
|
||||
{child.label}
|
||||
</NavLink>
|
||||
) : (
|
||||
@@ -308,8 +320,12 @@ export function Menu({
|
||||
// ---------------------------------------------------------------------------------
|
||||
// TODO: deprecate this once Theme is fully rolled out
|
||||
// Kept as is for backwards compatibility with the old theme system / superset_config.py
|
||||
//
|
||||
// `<Router basename={applicationRoot()}>` re-prepends the app root to the
|
||||
// `to` prop, so handing it an already-rooted `brand.path` would render a
|
||||
// doubled `/superset/superset/...` href. Strip the root first.
|
||||
link = (
|
||||
<GenericLink className="navbar-brand" to={brand.path}>
|
||||
<GenericLink className="navbar-brand" to={stripAppRoot(brand.path)}>
|
||||
<StyledImage
|
||||
preview={false}
|
||||
src={ensureStaticPrefix(brand.icon)}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
|
||||
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
||||
import * as getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import RightMenu from './RightMenu';
|
||||
import { GlobalMenuDataOptions, RightMenuProps } from './types';
|
||||
|
||||
@@ -477,3 +478,66 @@ test('hides logout button when embedded and flag is enabled', async () => {
|
||||
userEvent.hover(await screen.findByText(/Settings/i));
|
||||
expect(screen.queryByText('Logout')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Info link href is single-prefixed under subdirectory deployment', async () => {
|
||||
// Backend emits a bare leading-slash path (`/user_info/` or `/users/userinfo/`).
|
||||
// RightMenu wraps it with ensureAppRoot, which reads applicationRoot()
|
||||
// dynamically. Under SUPERSET_APP_ROOT=/superset the rendered href must
|
||||
// be exactly `/superset/users/userinfo/` — not `/users/userinfo/` (no
|
||||
// prefix → 404) or `/superset/superset/users/userinfo/` (double prefix).
|
||||
const applicationRootSpy = jest
|
||||
.spyOn(getBootstrapData, 'applicationRoot')
|
||||
.mockReturnValue('/superset');
|
||||
|
||||
try {
|
||||
resetUseSelectorMock();
|
||||
render(<RightMenu {...createProps()} />, {
|
||||
useRedux: true,
|
||||
useQueryParams: true,
|
||||
useRouter: true,
|
||||
useTheme: true,
|
||||
});
|
||||
|
||||
userEvent.hover(await screen.findByText(/Settings/i));
|
||||
const infoLink = await screen.findByText('Info');
|
||||
expect(infoLink.closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'/superset/users/userinfo/',
|
||||
);
|
||||
} finally {
|
||||
applicationRootSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('Logout link href is single-prefixed under subdirectory deployment', async () => {
|
||||
// The logout URL is built by Flask-AppBuilder's get_url_for_logout, which
|
||||
// is SCRIPT_NAME-aware and returns `/superset/logout/` under app_root.
|
||||
// The frontend then routes it through ensureAppRoot, whose idempotence
|
||||
// contract (see pathUtils.parity.test.ts) must prevent doubling.
|
||||
const applicationRootSpy = jest
|
||||
.spyOn(getBootstrapData, 'applicationRoot')
|
||||
.mockReturnValue('/superset');
|
||||
|
||||
try {
|
||||
const props = createProps();
|
||||
// Mirror the SCRIPT_NAME-prefixed value the backend would emit under
|
||||
// APPLICATION_ROOT=/superset.
|
||||
props.navbarRight.user_logout_url = '/superset/logout/';
|
||||
resetUseSelectorMock();
|
||||
render(<RightMenu {...props} />, {
|
||||
useRedux: true,
|
||||
useQueryParams: true,
|
||||
useRouter: true,
|
||||
useTheme: true,
|
||||
});
|
||||
|
||||
userEvent.hover(await screen.findByText(/Settings/i));
|
||||
const logoutLink = await screen.findByText('Logout');
|
||||
expect(logoutLink.closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'/superset/logout/',
|
||||
);
|
||||
} finally {
|
||||
applicationRootSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
TelemetryPixel,
|
||||
} from '@superset-ui/core/components';
|
||||
import type { ItemType, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
||||
import { ensureAppRoot, stripAppRoot } from 'src/utils/navigationUtils';
|
||||
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
@@ -409,7 +409,7 @@ const RightMenu = ({
|
||||
items.push({
|
||||
key: menu.label,
|
||||
label: isFrontendRoute(menu.url) ? (
|
||||
<Link to={menu.url || ''}>{menu.label}</Link>
|
||||
<Link to={stripAppRoot(menu.url || '')}>{menu.label}</Link>
|
||||
) : (
|
||||
<Typography.Link href={ensureAppRoot(menu.url || '')}>
|
||||
{menu.label}
|
||||
@@ -425,7 +425,7 @@ const RightMenu = ({
|
||||
items.push({
|
||||
key: menu.label,
|
||||
label: isFrontendRoute(menu.url) ? (
|
||||
<Link to={menu.url || ''}>{menu.label}</Link>
|
||||
<Link to={stripAppRoot(menu.url || '')}>{menu.label}</Link>
|
||||
) : (
|
||||
<Typography.Link href={ensureAppRoot(menu.url || '')}>
|
||||
{menu.label}
|
||||
@@ -460,7 +460,9 @@ const RightMenu = ({
|
||||
sectionItems.push({
|
||||
key: child.label,
|
||||
label: isFrontendRoute(child.url) ? (
|
||||
<Link to={child.url || ''}>{menuItemDisplay}</Link>
|
||||
<Link to={stripAppRoot(child.url || '')}>
|
||||
{menuItemDisplay}
|
||||
</Link>
|
||||
) : (
|
||||
<Typography.Link
|
||||
href={child.url || ''}
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
shortenSQL,
|
||||
} from 'src/views/CRUD/utils';
|
||||
import { assetUrl } from 'src/utils/assetUrl';
|
||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
||||
import { navigateTo } from 'src/utils/navigationUtils';
|
||||
import SubMenu from './SubMenu';
|
||||
import EmptyState from './EmptyState';
|
||||
@@ -306,7 +305,8 @@ export const SavedQueries = ({
|
||||
<CardStyles key={q.id}>
|
||||
<ListViewCard
|
||||
imgURL=""
|
||||
url={ensureAppRoot(`/sqllab?savedQueryId=${q.id}`)}
|
||||
url={`/sqllab?savedQueryId=${q.id}`}
|
||||
linkComponent={Link}
|
||||
title={q.label}
|
||||
imgFallbackURL={assetUrl(
|
||||
'/static/assets/images/empty-query.svg',
|
||||
|
||||
@@ -88,13 +88,13 @@ describe('logger middleware', () => {
|
||||
expect(next.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
test('should POST an event to /superset/log/ when called', () => {
|
||||
test('should POST an event to /log/ when called', () => {
|
||||
(logger as Function)(mockStore)(next)(action);
|
||||
expect(next.mock.calls.length).toBe(0);
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
expect(postStub.mock.calls.length).toBe(1);
|
||||
expect(postStub.mock.calls[0][0].endpoint).toMatch('/superset/log/');
|
||||
expect(postStub.mock.calls[0][0].endpoint).toMatch('/log/');
|
||||
});
|
||||
|
||||
test('should include ts, start_offset, event_name, impression_id, source, and source_id in every event', () => {
|
||||
@@ -160,7 +160,7 @@ describe('logger middleware', () => {
|
||||
|
||||
expect(beaconMock.mock.calls.length).toBe(1);
|
||||
const endpoint = beaconMock.mock.calls[0][0];
|
||||
expect(endpoint).toMatch('/superset/log/');
|
||||
expect(endpoint).toMatch('/log/');
|
||||
});
|
||||
|
||||
test('should pass a guest token to sendBeacon if present', () => {
|
||||
|
||||
@@ -29,11 +29,16 @@ import {
|
||||
LOG_ACTIONS_SPA_NAVIGATION,
|
||||
} from '../logger/LogUtils';
|
||||
import DebouncedMessageQueue from '../utils/DebouncedMessageQueue';
|
||||
import { ensureAppRoot } from '../utils/pathUtils';
|
||||
import { ensureAppRoot } from '../utils/navigationUtils';
|
||||
import type { DashboardInfo, DashboardLayoutState } from '../dashboard/types';
|
||||
import type { QueryEditor } from '../SqlLab/types';
|
||||
|
||||
type LogEventSource = 'dashboard' | 'embedded_dashboard' | 'explore' | 'sqlLab' | 'slice';
|
||||
type LogEventSource =
|
||||
| 'dashboard'
|
||||
| 'embedded_dashboard'
|
||||
| 'explore'
|
||||
| 'sqlLab'
|
||||
| 'slice';
|
||||
|
||||
interface LogEventData {
|
||||
source?: LogEventSource;
|
||||
@@ -88,7 +93,7 @@ interface LoggerStore {
|
||||
dispatch: Dispatch;
|
||||
}
|
||||
|
||||
const LOG_ENDPOINT = '/superset/log/?explode=events';
|
||||
const LOG_ENDPOINT = '/log/?explode=events';
|
||||
|
||||
const sendBeacon = (events: LogEventData[]): void => {
|
||||
if (events.length <= 0) {
|
||||
|
||||
@@ -42,7 +42,7 @@ import AnnotationLayerModal from 'src/features/annotationLayers/AnnotationLayerM
|
||||
import { AnnotationLayerObject } from 'src/features/annotationLayers/types';
|
||||
import { QueryObjectColumns } from 'src/views/CRUD/types';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { navigateTo } from 'src/utils/navigationUtils';
|
||||
import { ensureAppRoot, navigateTo } from 'src/utils/navigationUtils';
|
||||
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
@@ -154,7 +154,9 @@ function AnnotationLayersList({
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography.Link href={`/annotationlayer/${id}/annotation`}>
|
||||
<Typography.Link
|
||||
href={ensureAppRoot(`/annotationlayer/${id}/annotation`)}
|
||||
>
|
||||
{name}
|
||||
</Typography.Link>
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ import { AnnotationObject } from 'src/features/annotations/types';
|
||||
import AnnotationModal from 'src/features/annotations/AnnotationModal';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { Typography } from '@superset-ui/core/components/Typography';
|
||||
import { ensureAppRoot } from 'src/utils/navigationUtils';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -280,7 +281,7 @@ function AnnotationList({
|
||||
{hasHistory ? (
|
||||
<Link to="/annotationlayer/list/">{t('Back to all')}</Link>
|
||||
) : (
|
||||
<Typography.Link href="/annotationlayer/list/">
|
||||
<Typography.Link href={ensureAppRoot('/annotationlayer/list/')}>
|
||||
{t('Back to all')}
|
||||
</Typography.Link>
|
||||
)}
|
||||
|
||||
@@ -564,7 +564,7 @@ test('renders dashboard crosslinks as navigable links', async () => {
|
||||
within(crosslinks).getByRole('link', {
|
||||
name: new RegExp(dashboard.dashboard_title),
|
||||
}),
|
||||
).toHaveAttribute('href', `/superset/dashboard/${dashboard.id}`);
|
||||
).toHaveAttribute('href', `/dashboard/${dashboard.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -604,7 +604,7 @@ test('shows tag column when TAGGING_SYSTEM is enabled', async () => {
|
||||
|
||||
// Tag should be a link to all_entities page
|
||||
const tagLink = within(tag).getByRole('link');
|
||||
expect(tagLink).toHaveAttribute('href', '/superset/all_entities/?id=1');
|
||||
expect(tagLink).toHaveAttribute('href', '/all_entities/?id=1');
|
||||
expect(tagLink).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
} from 'src/components';
|
||||
import { Typography } from '@superset-ui/core/components/Typography';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { ensureAppRoot } from 'src/utils/navigationUtils';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
@@ -1032,7 +1033,7 @@ function DatabaseList({
|
||||
avatar={<span>•</span>}
|
||||
title={
|
||||
<Typography.Link
|
||||
href={`/superset/dashboard/${result.id}`}
|
||||
href={ensureAppRoot(`/dashboard/${result.id}`)}
|
||||
target="_atRiskItem"
|
||||
>
|
||||
{result.title}
|
||||
@@ -1075,7 +1076,9 @@ function DatabaseList({
|
||||
avatar={<span>•</span>}
|
||||
title={
|
||||
<Typography.Link
|
||||
href={`/explore/?slice_id=${result.id}`}
|
||||
href={ensureAppRoot(
|
||||
`/explore/?slice_id=${result.id}`,
|
||||
)}
|
||||
target="_atRiskItem"
|
||||
>
|
||||
{result.slice_name}
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
import type { SelectOption } from 'src/components/ListView/types';
|
||||
import { Typography } from '@superset-ui/core/components/Typography';
|
||||
import handleResourceExport from 'src/utils/export';
|
||||
import { ensureAppRoot } from 'src/utils/navigationUtils';
|
||||
import SubMenu, { SubMenuProps, ButtonProps } from 'src/features/home/SubMenu';
|
||||
import Owner from 'src/types/Owner';
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
@@ -1364,7 +1365,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
avatar={<span>•</span>}
|
||||
title={
|
||||
<Typography.Link
|
||||
href={`/superset/dashboard/${result.id}`}
|
||||
href={ensureAppRoot(`/dashboard/${result.id}`)}
|
||||
target="_atRiskItem"
|
||||
>
|
||||
{result.title}
|
||||
@@ -1407,7 +1408,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
avatar={<span>•</span>}
|
||||
title={
|
||||
<Typography.Link
|
||||
href={`/explore/?slice_id=${result.id}`}
|
||||
href={ensureAppRoot(
|
||||
`/explore/?slice_id=${result.id}`,
|
||||
)}
|
||||
target="_atRiskItem"
|
||||
>
|
||||
{result.slice_name}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 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 { ComponentType } from 'react';
|
||||
import { Route, Router } from 'react-router-dom';
|
||||
import { createMemoryHistory, MemoryHistory } from 'history';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import FileHandler from './index';
|
||||
|
||||
// Subdirectory regression for the five `history.push('/welcome/')` emitters
|
||||
// in FileHandler. The sibling `index.test.tsx` mocks `useHistory` and so can
|
||||
// only assert the value the emitter passes to `push`; this module wires up
|
||||
// the real react-router pathway (real `<Router>` + a `createMemoryHistory`
|
||||
// the test owns) and asserts that the resulting `history.location.pathname`
|
||||
// resolves to `/welcome/` regardless of whether the user arrived under the
|
||||
// root deployment (`/file-handler`) or a `/superset` subdirectory deployment
|
||||
// (`/superset/file-handler`).
|
||||
//
|
||||
// Note on basename composition: Superset's dependency tree pairs
|
||||
// `react-router-dom@5.3.4` with `history@5.3.0`, but the `basename` prop on
|
||||
// `<BrowserRouter>` is silently dropped in that combination (history v5 has
|
||||
// no basename support; react-router-dom v5 was designed for history v4). The
|
||||
// production subdirectory deployment composes the prefix at the URL-emitting
|
||||
// layer via `applicationRoot()` in non-router callers (see
|
||||
// `SliceHeaderControls.subdirectory.test.tsx`), and the in-app router relies
|
||||
// on the unprefixed routes matching whatever path the user arrived at. As a
|
||||
// result, asserting `history.createHref(...)` here would not exercise any
|
||||
// real composition — so this module pins only what is meaningful in this
|
||||
// stack: that the emitter pushes the unprefixed route and the post-push
|
||||
// location reflects it.
|
||||
|
||||
const mockAddDangerToast = jest.fn();
|
||||
const mockAddSuccessToast = jest.fn();
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
type ToastInjectedProps = {
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
};
|
||||
|
||||
jest.mock('src/components/MessageToasts/withToasts', () => ({
|
||||
__esModule: true,
|
||||
default: (Component: ComponentType<ToastInjectedProps>) =>
|
||||
function MockedWithToasts(props: Record<string, unknown>) {
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
addDangerToast={mockAddDangerToast}
|
||||
addSuccessToast={mockAddSuccessToast}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
interface UploadDataModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
type: string;
|
||||
allowedExtensions: string[];
|
||||
fileListOverride?: File[];
|
||||
}
|
||||
|
||||
jest.mock('src/features/databases/UploadDataModel', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
show,
|
||||
onHide,
|
||||
type,
|
||||
allowedExtensions,
|
||||
fileListOverride,
|
||||
}: UploadDataModalProps) => (
|
||||
<div data-test="upload-modal">
|
||||
<div data-test="modal-show">{show.toString()}</div>
|
||||
<div data-test="modal-type">{type}</div>
|
||||
<div data-test="modal-extensions">{allowedExtensions.join(',')}</div>
|
||||
<div data-test="modal-file">{fileListOverride?.[0]?.name ?? ''}</div>
|
||||
<button onClick={onHide} type="button">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// NOTE: deliberately NO jest.mock('react-router-dom', ...) here — this module
|
||||
// exists precisely to exercise the real useHistory() pathway, not a mock.
|
||||
|
||||
type MockFileHandle = {
|
||||
kind: 'file';
|
||||
name: string;
|
||||
getFile: () => Promise<File>;
|
||||
isSameEntry: () => Promise<boolean>;
|
||||
queryPermission: () => Promise<PermissionState>;
|
||||
requestPermission: () => Promise<PermissionState>;
|
||||
};
|
||||
|
||||
const createMockFileHandle = (
|
||||
fileName: string,
|
||||
opts: { throwOnGetFile?: boolean } = {},
|
||||
): MockFileHandle => ({
|
||||
kind: 'file',
|
||||
name: fileName,
|
||||
getFile: opts.throwOnGetFile
|
||||
? async () => {
|
||||
throw new Error('File access denied');
|
||||
}
|
||||
: async () => new File(['test'], fileName),
|
||||
isSameEntry: async () => false,
|
||||
queryPermission: async () => 'granted',
|
||||
requestPermission: async () => 'granted',
|
||||
});
|
||||
|
||||
type LaunchQueue = {
|
||||
setConsumer: (
|
||||
consumer: (params: { files?: MockFileHandle[] }) => void,
|
||||
) => void;
|
||||
};
|
||||
|
||||
const pendingTimerIds = new Set<ReturnType<typeof setTimeout>>();
|
||||
const MAX_CONSUMER_POLL_ATTEMPTS = 50;
|
||||
|
||||
// Mirrors `setupLaunchQueue` in index.test.tsx: defer the consumer to a
|
||||
// macrotask so it doesn't fire synchronously inside the component's useEffect
|
||||
// (the MessageChannel mock in jsDomWithFetchAPI forces React to schedule via
|
||||
// setTimeout, and inline consumer calls deadlock Jest).
|
||||
const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
|
||||
let savedConsumer:
|
||||
| ((params: { files?: MockFileHandle[] }) => void | Promise<void>)
|
||||
| null = null;
|
||||
(window as unknown as Window & { launchQueue: LaunchQueue }).launchQueue = {
|
||||
setConsumer: (consumer: (params: { files?: MockFileHandle[] }) => void) => {
|
||||
savedConsumer = consumer;
|
||||
if (fileHandle) {
|
||||
const id = setTimeout(() => {
|
||||
pendingTimerIds.delete(id);
|
||||
consumer({ files: [fileHandle] });
|
||||
}, 0);
|
||||
pendingTimerIds.add(id);
|
||||
}
|
||||
},
|
||||
};
|
||||
return {
|
||||
triggerConsumer: async (params: { files?: MockFileHandle[] }) => {
|
||||
let attempts = 0;
|
||||
while (!savedConsumer && attempts < MAX_CONSUMER_POLL_ATTEMPTS) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
attempts += 1;
|
||||
}
|
||||
if (!savedConsumer) {
|
||||
throw new Error(
|
||||
`LaunchQueue consumer was never registered after ${MAX_CONSUMER_POLL_ATTEMPTS} polling attempts`,
|
||||
);
|
||||
}
|
||||
await savedConsumer(params);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type DeploymentLabel = 'root' | 'subdir';
|
||||
|
||||
const ENTRY_PATHS: Record<DeploymentLabel, string> = {
|
||||
root: '/file-handler',
|
||||
subdir: '/superset/file-handler',
|
||||
};
|
||||
|
||||
const renderUnderEntry = (entryPath: string): MemoryHistory => {
|
||||
const history = createMemoryHistory({ initialEntries: [entryPath] });
|
||||
render(
|
||||
<Router history={history}>
|
||||
<Route path={entryPath}>
|
||||
<FileHandler />
|
||||
</Route>
|
||||
</Router>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
return history;
|
||||
};
|
||||
|
||||
const expectNavigatedToWelcome = async (
|
||||
history: MemoryHistory,
|
||||
): Promise<void> => {
|
||||
await waitFor(() => {
|
||||
expect(history.location.pathname).toBe('/welcome/');
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete (window as unknown as Window & { launchQueue?: LaunchQueue })
|
||||
.launchQueue;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
pendingTimerIds.forEach(id => clearTimeout(id));
|
||||
pendingTimerIds.clear();
|
||||
delete (window as unknown as Window & { launchQueue?: LaunchQueue })
|
||||
.launchQueue;
|
||||
});
|
||||
|
||||
// Run each redirect scenario under both deployment shapes. Both must end at
|
||||
// `/welcome/`; the test fails if a future maintainer re-introduces the
|
||||
// `/superset/` prefix into the emitter at any of the five call sites.
|
||||
const DEPLOYMENTS: DeploymentLabel[] = ['root', 'subdir'];
|
||||
|
||||
DEPLOYMENTS.forEach(label => {
|
||||
const entryPath = ENTRY_PATHS[label];
|
||||
|
||||
test(`launchQueue unsupported → /welcome/ under ${label} (${entryPath})`, async () => {
|
||||
const history = renderUnderEntry(entryPath);
|
||||
await expectNavigatedToWelcome(history);
|
||||
});
|
||||
|
||||
test(`no files provided → /welcome/ under ${label} (${entryPath})`, async () => {
|
||||
const { triggerConsumer } = setupLaunchQueue();
|
||||
const history = renderUnderEntry(entryPath);
|
||||
await triggerConsumer({ files: [] });
|
||||
await expectNavigatedToWelcome(history);
|
||||
});
|
||||
|
||||
test(`unsupported file type → /welcome/ under ${label} (${entryPath})`, async () => {
|
||||
const { triggerConsumer } = setupLaunchQueue();
|
||||
const history = renderUnderEntry(entryPath);
|
||||
await triggerConsumer({ files: [createMockFileHandle('test.pdf')] });
|
||||
await expectNavigatedToWelcome(history);
|
||||
});
|
||||
|
||||
test(`getFile() error → /welcome/ under ${label} (${entryPath})`, async () => {
|
||||
const { triggerConsumer } = setupLaunchQueue();
|
||||
const history = renderUnderEntry(entryPath);
|
||||
await triggerConsumer({
|
||||
files: [createMockFileHandle('test.csv', { throwOnGetFile: true })],
|
||||
});
|
||||
await expectNavigatedToWelcome(history);
|
||||
});
|
||||
|
||||
test(`modal close → /welcome/ under ${label} (${entryPath})`, async () => {
|
||||
setupLaunchQueue(createMockFileHandle('test.csv'));
|
||||
const history = renderUnderEntry(entryPath);
|
||||
const modal = await screen.findByTestId('upload-modal');
|
||||
expect(modal).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
await expectNavigatedToWelcome(history);
|
||||
});
|
||||
});
|
||||
@@ -201,7 +201,7 @@ test('shows error when launchQueue is not supported', async () => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
'File handling is not supported in this browser. Please use a modern browser like Chrome or Edge.',
|
||||
);
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith('/welcome/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,7 +221,7 @@ test('redirects when no files are provided', async () => {
|
||||
await triggerConsumer({ files: [] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith('/welcome/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -326,7 +326,7 @@ test('shows error for unsupported file type', async () => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
'Unsupported file type. Please use CSV, Excel, or Columnar files.',
|
||||
);
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith('/welcome/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -378,7 +378,7 @@ test('handles errors during file processing', async () => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
'Failed to open file. Please try again.',
|
||||
);
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith('/welcome/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -402,7 +402,7 @@ test('modal close redirects to welcome page', async () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith('/welcome/');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user