mirror of
https://github.com/apache/superset.git
synced 2026-08-27 18:41:20 +00:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7318a6e7c | ||
|
|
95f7589306 | ||
|
|
fe2424ec14 | ||
|
|
b4f43bd7e0 | ||
|
|
2b25345ed9 | ||
|
|
e0f3f93cd4 | ||
|
|
0667ba6097 | ||
|
|
81f7e42f4e | ||
|
|
0fd244b5c6 | ||
|
|
1f16d10cbf | ||
|
|
4f4663418f | ||
|
|
4519a5c52d | ||
|
|
da9fbadaf6 | ||
|
|
f40abbbefd | ||
|
|
6166af3c3c | ||
|
|
076d8c1508 | ||
|
|
518cadd907 | ||
|
|
b955c90de4 | ||
|
|
7363774869 | ||
|
|
6f12d17313 | ||
|
|
09c7ba14df | ||
|
|
3ec4bd23c4 | ||
|
|
f6ce105450 | ||
|
|
7bb4e82a82 | ||
|
|
2d78a8733c | ||
|
|
3261d10270 | ||
|
|
a57b5f6078 | ||
|
|
d1b523b97f | ||
|
|
91188a0302 | ||
|
|
ac234d0fb2 | ||
|
|
8eb753eab2 | ||
|
|
779fa13679 | ||
|
|
caf81e71d2 | ||
|
|
1b8c6d109d | ||
|
|
eb60e5477b | ||
|
|
7b9bcdd951 | ||
|
|
d9d395bde1 | ||
|
|
584d41759b | ||
|
|
8f22b71898 | ||
|
|
1ea3584dcb |
@@ -24,6 +24,14 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### Guest-token RLS rules reject unknown fields
|
||||
|
||||
The `rls` rules passed to `POST /api/v1/security/guest_token/` are now validated strictly: a rule may only contain `dataset` and `clause`. Previously unknown fields were silently dropped, so a mistyped or legacy scope key (most commonly `datasource` instead of `dataset`) produced a rule with no `dataset`, which is treated as a *global* rule applied to every dataset the embedded resource can reach. Such a request now returns HTTP 400 identifying the offending field instead of issuing a token with an unintended global rule. Integrators that were sending extra fields in RLS rules must remove them; valid dataset-scoped (`{"dataset": 41, "clause": "..."}`) and global (`{"clause": "..."}`) rules are unaffected.
|
||||
|
||||
### MCP service requires `MCP_JWT_AUDIENCE` when JWT auth is enabled
|
||||
|
||||
When the MCP service has JWT auth enabled (`MCP_AUTH_ENABLED = True`), an audience must be configured via `MCP_JWT_AUDIENCE` so issued tokens are bound to this service. The service now fails to start with a clear configuration error when the audience is unset, instead of starting with audience validation skipped. Deployments that enable MCP JWT auth must set `MCP_JWT_AUDIENCE` to the audience value their identity provider issues for the MCP service. API-key-only MCP deployments (JWT auth disabled) are unaffected.
|
||||
|
||||
### Pivot table First/Last aggregations follow data order
|
||||
|
||||
The pivot table chart's `First` and `Last` aggregations now return the first and last value in data (query result) order, instead of effectively returning the minimum and maximum. Existing pivot tables that use these aggregations for totals/subtotals may show different values after upgrading. For deterministic results, ensure the underlying query has a stable sort order.
|
||||
|
||||
@@ -161,6 +161,7 @@ Here's the documentation section how how to set up Talisman: https://superset.ap
|
||||
|
||||
- [ ] Regularly update to the latest major or minor versions of Superset. Those versions receive up-to-date security patches.
|
||||
- [ ] Rotate the `SUPERSET_SECRET_KEY` periodically (e.g., quarterly) and after any potential security incident.
|
||||
- [ ] Rotate the other security-critical secrets (guest-token and async-query JWT secrets, SMTP and database credentials) on the cadence in Appendix C, and after any potential security incident.
|
||||
- [ ] Conduct quarterly access reviews for all users.
|
||||
- [ ] Assuming logging and monitoring is in place, review security monitoring alerts weekly.
|
||||
|
||||
@@ -173,6 +174,24 @@ Rotating the `SUPERSET_SECRET_KEY` is a critical security procedure. It is manda
|
||||
The procedure for safely rotating the SECRET_KEY must be followed precisely to avoid locking yourself out of your instance. The official Apache Superset documentation maintains the correct, up-to-date procedure. Please follow the official guide here:
|
||||
https://superset.apache.org/admin-docs/configuration/configuring-superset/#rotating-to-a-newer-secret_key
|
||||
|
||||
### **Appendix C: Secrets Register and Rotation Schedule**
|
||||
|
||||
`SUPERSET_SECRET_KEY` is not the only security-critical secret in a Superset deployment. Maintain an inventory of all such secrets, store each in a secrets manager (not in `superset_config.py` or version control), assign an owner, and rotate them on a defined cadence as well as after any suspected compromise.
|
||||
|
||||
| Secret | Purpose | Risk if leaked | Suggested rotation |
|
||||
|---|---|---|---|
|
||||
| `SUPERSET_SECRET_KEY` | Signs session cookies; key material for encrypting stored DB credentials (Fernet/AES) | Forged sessions (auth bypass / privilege escalation); decryption of exfiltrated metadata-DB secrets | Quarterly + post-incident |
|
||||
| `GUEST_TOKEN_JWT_SECRET` | Signs embedded-dashboard guest tokens | Forged guest tokens → unauthorized dashboard/data access | Quarterly + post-incident |
|
||||
| `GLOBAL_ASYNC_QUERIES_JWT_SECRET` | Signs the async-query channel JWT | Forged async-query tokens | Quarterly + post-incident |
|
||||
| SMTP password | Outbound email for alerts & reports | Email relay abuse / spoofing | Per organizational policy + post-incident |
|
||||
| Database connection passwords | Access to analytical databases and the metadata DB | Direct database access | Per organizational policy + post-incident |
|
||||
|
||||
Notes:
|
||||
|
||||
- Rotating `GUEST_TOKEN_JWT_SECRET` or `GLOBAL_ASYNC_QUERIES_JWT_SECRET` invalidates outstanding tokens of that type; schedule rotations accordingly.
|
||||
- After a suspected compromise, rotate **all** of the above, not only `SUPERSET_SECRET_KEY`.
|
||||
- Keep the register under change control so new secrets introduced by future features are added to the rotation schedule.
|
||||
|
||||
:::resources
|
||||
- [Blog: Running Apache Superset on the Open Internet](https://preset.io/blog/running-apache-superset-on-the-open-internet-a-report-from-the-fireline/)
|
||||
- [Blog: How Security Vulnerabilities are Reported & Handled in Apache Superset](https://preset.io/blog/how-security-vulnerabilities-are-reported-and-handled-in-apache-superset/)
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.41",
|
||||
"antd": "^6.4.4",
|
||||
"baseline-browser-mapping": "^2.10.37",
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
|
||||
+4
-4
@@ -5698,10 +5698,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.37, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.37"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz#3e636475b6b293244e2b23e2c71a2ab9d9e6ba7d"
|
||||
integrity sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==
|
||||
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.38"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz#c84d093c4bf7325c5053c279d90f153c66526042"
|
||||
integrity sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
|
||||
@@ -29,7 +29,7 @@ maintainers:
|
||||
- name: craig-rueda
|
||||
email: craig@craigrueda.com
|
||||
url: https://github.com/craig-rueda
|
||||
version: 0.17.2 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
version: 0.17.3 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 16.7.27
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
|
||||
@@ -108,8 +108,6 @@ else:
|
||||
{{ fail (printf "Unsupported database type: %s. Please use 'postgresql' or 'mysql'." .Values.supersetNode.connections.db_type) }}
|
||||
{{- end }}
|
||||
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = True
|
||||
|
||||
class CeleryConfig:
|
||||
imports = ("superset.sql_lab", )
|
||||
broker_url = CELERY_REDIS_URL
|
||||
|
||||
@@ -315,7 +315,7 @@ pygeohash==3.2.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
pygments==2.20.0
|
||||
# via rich
|
||||
pyjwt==2.12.0
|
||||
pyjwt==2.13.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
|
||||
@@ -769,7 +769,7 @@ pyhive==0.7.0
|
||||
# via apache-superset
|
||||
pyinstrument==5.1.2
|
||||
# via apache-superset
|
||||
pyjwt==2.12.0
|
||||
pyjwt==2.13.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
Generated
+16
-156
@@ -109,7 +109,7 @@
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"mapbox-gl": "^3.24.1",
|
||||
"mapbox-gl": "^3.25.0",
|
||||
"markdown-to-jsx": "^9.8.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -220,7 +220,7 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.37",
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -6326,12 +6326,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/mapbox-gl-supported": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz",
|
||||
"integrity": "sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/martini": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/martini/-/martini-0.2.0.tgz",
|
||||
@@ -11470,15 +11464,6 @@
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson-vt": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
|
||||
"integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/glob-to-regexp": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/glob-to-regexp/-/glob-to-regexp-0.4.4.tgz",
|
||||
@@ -11776,12 +11761,6 @@
|
||||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/pbf": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
|
||||
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz",
|
||||
@@ -14961,9 +14940,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.37",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
|
||||
"integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
|
||||
"version": "2.10.38",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
|
||||
"integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -15954,12 +15933,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/cheap-ruler": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz",
|
||||
"integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
@@ -17283,12 +17256,6 @@
|
||||
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csscolorparser": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz",
|
||||
"integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -18617,11 +18584,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
|
||||
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
|
||||
"version": "3.4.11",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
@@ -21450,12 +21416,6 @@
|
||||
"integrity": "sha512-k/6BCd0qAt7vdqdM1LkLfAy72EsLDy0laNwX0x2h49vfYCiQkRc4PSra8DNEdJ10EKRpwEvDXMb0dBknTJuWpQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/geojson-vt": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
|
||||
"integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/geolib": {
|
||||
"version": "3.3.14",
|
||||
"resolved": "https://registry.npmjs.org/geolib/-/geolib-3.3.14.tgz",
|
||||
@@ -23260,9 +23220,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-middleware": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
|
||||
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
|
||||
"integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -28361,9 +28321,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl": {
|
||||
"version": "3.24.1",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.24.1.tgz",
|
||||
"integrity": "sha512-e9Wj1TtGGOjzE/jtWaUvdFN7RYL3H0keEzH7gwzHbEdFAsmi03RaDVhnATmtFtIRXQUYf944CIQN0jQv+obeNg==",
|
||||
"version": "3.25.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.25.0.tgz",
|
||||
"integrity": "sha512-I+9oSkJVFu51xIAAQcjKophFe6zVAGWROHsszeRhX9E1OXEizgPH+8BkF7GaxmmLd9FbADdEfvULF8NxEFcB5w==",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"workspaces": [
|
||||
"src/style-spec",
|
||||
@@ -28371,66 +28331,7 @@
|
||||
"test/build/vite",
|
||||
"test/build/webpack",
|
||||
"test/build/typings"
|
||||
],
|
||||
"dependencies": {
|
||||
"@mapbox/mapbox-gl-supported": "^3.0.0",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.0.6",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/geojson-vt": "^3.2.5",
|
||||
"@types/pbf": "^3.0.5",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"cheap-ruler": "^4.0.0",
|
||||
"csscolorparser": "~1.0.3",
|
||||
"earcut": "^3.0.1",
|
||||
"geojson-vt": "^4.0.2",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.0.2",
|
||||
"martinez-polygon-clipping": "^0.8.1",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^4.0.1",
|
||||
"potpack": "^2.0.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"supercluster": "^8.0.1",
|
||||
"tinyqueue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl/node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mapbox-gl/node_modules/@mapbox/vector-tile": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
|
||||
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl/node_modules/earcut": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mapbox-gl/node_modules/pbf": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
|
||||
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "5.24.0",
|
||||
@@ -28548,23 +28449,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/martinez-polygon-clipping": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/martinez-polygon-clipping/-/martinez-polygon-clipping-0.8.1.tgz",
|
||||
"integrity": "sha512-9PLLMzMPI6ihHox4Ns6LpVBLpRc7sbhULybZ/wyaY8sY3ECNe2+hxm1hA2/9bEEpRrdpjoeduBuZLg2aq1cSIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^2.0.4",
|
||||
"splaytree": "^0.1.4",
|
||||
"tinyqueue": "3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/martinez-polygon-clipping/node_modules/robust-predicates": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz",
|
||||
"integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/match-sorter": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-8.3.0.tgz",
|
||||
@@ -39137,12 +39021,6 @@
|
||||
"webpack": "^1 || ^2 || ^3 || ^4 || ^5"
|
||||
}
|
||||
},
|
||||
"node_modules/splaytree": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/splaytree/-/splaytree-0.1.4.tgz",
|
||||
"integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/split": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz",
|
||||
@@ -45044,15 +44922,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/dompurify": {
|
||||
"version": "3.4.11",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-ace": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
|
||||
@@ -45423,15 +45292,6 @@
|
||||
"react": "^18.3.0"
|
||||
}
|
||||
},
|
||||
"plugins/legacy-preset-chart-nvd3/node_modules/dompurify": {
|
||||
"version": "3.4.11",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-ag-grid-table": {
|
||||
"name": "@superset-ui/plugin-chart-ag-grid-table",
|
||||
"version": "0.20.3",
|
||||
@@ -45611,7 +45471,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.24.1",
|
||||
"mapbox-gl": "^3.25.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.1",
|
||||
"supercluster": "^8.0.1"
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"mapbox-gl": "^3.24.1",
|
||||
"mapbox-gl": "^3.25.0",
|
||||
"markdown-to-jsx": "^9.8.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -303,7 +303,7 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.37",
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
|
||||
@@ -22,21 +22,50 @@ under the License.
|
||||
[](https://www.npmjs.com/package/@superset-ui/core)
|
||||
[](https://libraries.io/npm/@superset-ui%2Fcore)
|
||||
|
||||
Description
|
||||
The core package for Apache Superset's frontend. It provides shared utilities,
|
||||
types, and abstractions used across all Superset chart plugins and UI components.
|
||||
|
||||
Key modules include:
|
||||
|
||||
- **query** — Utilities for building queries and calling the Superset API
|
||||
(including `makeApi`)
|
||||
- **number-format** — Number formatting helpers powered by d3-format
|
||||
- **time-format** — Time/date formatting helpers powered by d3-time-format
|
||||
- **connection** — `SupersetClient`, the HTTP client for the Superset REST API
|
||||
- **chart** — Base classes and types for building chart plugins
|
||||
|
||||
> **Note:** i18n utilities (`t`, `tn`, etc.) are no longer part of this package.
|
||||
> They now live in `@apache-superset/core`, imported from
|
||||
> `@apache-superset/core/translation`.
|
||||
|
||||
#### Example usage
|
||||
|
||||
```js
|
||||
import { xxx } from '@superset-ui/core';
|
||||
import { getNumberFormatter, makeApi } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
|
||||
// Format a number
|
||||
const formatter = getNumberFormatter('.2f');
|
||||
console.log(formatter(1234.5)); // "1234.50"
|
||||
|
||||
// Translate a string
|
||||
console.log(t('Hello %s', 'world'));
|
||||
|
||||
// Call a Superset API endpoint
|
||||
const fetchDashboards = makeApi({
|
||||
method: 'GET',
|
||||
endpoint: '/api/v1/dashboard',
|
||||
});
|
||||
```
|
||||
|
||||
#### API
|
||||
|
||||
`fn(args)`
|
||||
|
||||
- TBD
|
||||
|
||||
### Development
|
||||
|
||||
`@data-ui/build-config` is used to manage the build configuration for this package including babel
|
||||
builds, jest testing, eslint, and prettier.
|
||||
`@data-ui/build-config` is used to manage the build configuration for this package
|
||||
including babel builds, jest testing, eslint, and prettier.
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
cd superset-frontend
|
||||
npx jest packages/superset-ui-core
|
||||
```
|
||||
|
||||
+10
-3
@@ -22,7 +22,7 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||
// remark-gfm v4+ requires react-markdown v9+, which requires React 18.
|
||||
// Currently pinned to v3.0.1 for compatibility with react-markdown v8 and React 17.
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { mergeWith } from 'lodash';
|
||||
import { cloneDeep, mergeWith } from 'lodash';
|
||||
import { FeatureFlag, isFeatureEnabled } from '../../utils';
|
||||
|
||||
interface SafeMarkdownProps {
|
||||
@@ -85,8 +85,15 @@ export function getOverrideHtmlSchema(
|
||||
originalSchema: typeof defaultSchema,
|
||||
htmlSchemaOverrides: SafeMarkdownProps['htmlSchemaOverrides'],
|
||||
) {
|
||||
return mergeWith(originalSchema, htmlSchemaOverrides, (objValue, srcValue) =>
|
||||
Array.isArray(objValue) ? objValue.concat(srcValue) : undefined,
|
||||
// Merge into a fresh clone: mergeWith mutates its first argument, and the
|
||||
// array customizer concatenates, so merging into the shared defaultSchema
|
||||
// import would progressively widen the sanitization allowlist for every
|
||||
// SafeMarkdown instance app-wide.
|
||||
return mergeWith(
|
||||
cloneDeep(originalSchema),
|
||||
htmlSchemaOverrides,
|
||||
(objValue, srcValue) =>
|
||||
Array.isArray(objValue) ? objValue.concat(srcValue) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ const SupersetClient: SupersetClientInterface = {
|
||||
request: request => getInstance().request(request),
|
||||
getCSRFToken: () => getInstance().getCSRFToken(),
|
||||
getUrl: (...args) => getInstance().getUrl(...args),
|
||||
postBlob: (endpoint, payload) => getInstance().postBlob(endpoint, payload),
|
||||
get guestTokenHeaderName() {
|
||||
try {
|
||||
return getInstance().guestTokenHeaderName;
|
||||
|
||||
@@ -150,6 +150,26 @@ export default class SupersetClientClass {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request that returns a blob for file downloads.
|
||||
* Unlike postForm, this uses AJAX so errors can be caught and handled.
|
||||
* @param endpoint - API endpoint
|
||||
* @param payload - Request payload
|
||||
* @returns Promise resolving to Response with blob
|
||||
*/
|
||||
async postBlob(
|
||||
endpoint: string,
|
||||
payload: Record<string, any>,
|
||||
): Promise<Response> {
|
||||
await this.ensureAuth();
|
||||
return this.post({
|
||||
endpoint,
|
||||
postPayload: payload,
|
||||
parseMethod: 'raw',
|
||||
stringify: false,
|
||||
});
|
||||
}
|
||||
|
||||
async reAuthenticate() {
|
||||
return this.init(true);
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ export interface SupersetClientInterface extends Pick<
|
||||
| 'get'
|
||||
| 'post'
|
||||
| 'postForm'
|
||||
| 'postBlob'
|
||||
| 'put'
|
||||
| 'request'
|
||||
| 'init'
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { defaultSchema } from 'rehype-sanitize';
|
||||
import {
|
||||
getOverrideHtmlSchema,
|
||||
SafeMarkdown,
|
||||
@@ -51,6 +53,36 @@ describe('getOverrideHtmlSchema', () => {
|
||||
expect(result.attributes).toEqual({ '*': ['size', 'src'], h1: ['style'] });
|
||||
expect(result.tagNames).toEqual(['h1', 'h2', 'h3', 'iframe']);
|
||||
});
|
||||
|
||||
test('should not mutate the original schema', () => {
|
||||
const original = {
|
||||
attributes: { '*': ['size'] },
|
||||
tagNames: ['h1'],
|
||||
};
|
||||
getOverrideHtmlSchema(original, {
|
||||
attributes: { '*': ['src'] },
|
||||
tagNames: ['iframe'],
|
||||
});
|
||||
// The original passed in is left untouched.
|
||||
expect(original.attributes).toEqual({ '*': ['size'] });
|
||||
expect(original.tagNames).toEqual(['h1']);
|
||||
});
|
||||
|
||||
test('should not mutate the shared defaultSchema import or accumulate across calls', () => {
|
||||
const snapshot = cloneDeep(defaultSchema);
|
||||
const overrides = { tagNames: ['iframe'] };
|
||||
|
||||
const first = getOverrideHtmlSchema(defaultSchema, overrides);
|
||||
const second = getOverrideHtmlSchema(defaultSchema, overrides);
|
||||
|
||||
// The shared singleton is never modified...
|
||||
expect(defaultSchema).toEqual(snapshot);
|
||||
// ...and repeated calls do not accumulate the override (no growing arrays).
|
||||
expect(first.tagNames).toEqual(second.tagNames);
|
||||
expect(
|
||||
(second.tagNames ?? []).filter(name => name === 'iframe'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformLinkUri', () => {
|
||||
|
||||
@@ -36,12 +36,13 @@ describe('SupersetClient', () => {
|
||||
getUrl: (...args: unknown[]) => string;
|
||||
};
|
||||
|
||||
test('exposes configure, init, get, post, postForm, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
|
||||
test('exposes configure, init, get, post, postForm, postBlob, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
|
||||
expect(typeof SupersetClient.configure).toBe('function');
|
||||
expect(typeof SupersetClient.init).toBe('function');
|
||||
expect(typeof SupersetClient.get).toBe('function');
|
||||
expect(typeof SupersetClient.post).toBe('function');
|
||||
expect(typeof SupersetClient.postForm).toBe('function');
|
||||
expect(typeof SupersetClient.postBlob).toBe('function');
|
||||
expect(typeof SupersetClient.delete).toBe('function');
|
||||
expect(typeof SupersetClient.put).toBe('function');
|
||||
expect(typeof SupersetClient.request).toBe('function');
|
||||
@@ -53,11 +54,12 @@ describe('SupersetClient', () => {
|
||||
expect(typeof SupersetClient.reAuthenticate).toBe('function');
|
||||
});
|
||||
|
||||
test('throws if you call init, get, post, postForm, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
|
||||
test('throws if you call init, get, post, postForm, postBlob, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
|
||||
expect(SupersetClient.init).toThrow();
|
||||
expect(SupersetClient.get).toThrow();
|
||||
expect(SupersetClient.post).toThrow();
|
||||
expect(SupersetClient.postForm).toThrow();
|
||||
expect(SupersetClient.postBlob).toThrow();
|
||||
expect(SupersetClient.delete).toThrow();
|
||||
expect(SupersetClient.put).toThrow();
|
||||
expect(SupersetClient.request).toThrow();
|
||||
|
||||
+71
@@ -780,4 +780,75 @@ describe('SupersetClientClass', () => {
|
||||
expect(authSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('.postBlob()', () => {
|
||||
const protocol = 'https:';
|
||||
const host = 'host';
|
||||
const mockPostBlobEndpoint = '/api/v1/chart/data';
|
||||
const mockPostBlobUrl = `${protocol}//${host}${mockPostBlobEndpoint}`;
|
||||
const postBlobPayload = { form_data: '{"viz_type":"table"}' };
|
||||
|
||||
let authSpy: jest.SpyInstance;
|
||||
let client: SupersetClientClass;
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchMock.removeRoute(LOGIN_GLOB);
|
||||
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB });
|
||||
|
||||
client = new SupersetClientClass({ protocol, host });
|
||||
await client.init();
|
||||
authSpy = jest.spyOn(SupersetClientClass.prototype, 'ensureAuth');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('calls ensureAuth and delegates to post with raw parseMethod', async () => {
|
||||
const mockResponse = new Response('csv data', { status: 200 });
|
||||
const postSpy = jest
|
||||
.spyOn(client, 'post')
|
||||
.mockResolvedValue(mockResponse);
|
||||
|
||||
const response = await client.postBlob(
|
||||
mockPostBlobEndpoint,
|
||||
postBlobPayload,
|
||||
);
|
||||
|
||||
expect(authSpy).toHaveBeenCalledTimes(1);
|
||||
expect(postSpy).toHaveBeenCalledWith({
|
||||
endpoint: mockPostBlobEndpoint,
|
||||
postPayload: postBlobPayload,
|
||||
parseMethod: 'raw',
|
||||
stringify: false,
|
||||
});
|
||||
expect(response).toBe(mockResponse);
|
||||
});
|
||||
|
||||
test('passes payload in request body', async () => {
|
||||
fetchMock.post(mockPostBlobUrl, {
|
||||
status: 200,
|
||||
body: 'csv data',
|
||||
});
|
||||
|
||||
await client.postBlob(mockPostBlobEndpoint, postBlobPayload);
|
||||
|
||||
const fetchRequest = fetchMock.callHistory.calls(mockPostBlobUrl)[0]
|
||||
.options as CallApi;
|
||||
const formData = fetchRequest.body as FormData;
|
||||
|
||||
expect(formData.get('form_data')).toBe(postBlobPayload.form_data);
|
||||
});
|
||||
|
||||
test('rejects when response is not ok', async () => {
|
||||
fetchMock.post(mockPostBlobUrl, {
|
||||
status: 413,
|
||||
body: 'Payload Too Large',
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.postBlob(mockPostBlobEndpoint, postBlobPayload),
|
||||
).rejects.toMatchObject({ status: 413 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,13 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
let { metrics, orderby = [], columns = [] } = baseQueryObject;
|
||||
const { extras = {} } = baseQueryObject;
|
||||
let postProcessing: PostProcessingRule[] = [];
|
||||
// Capture the percent-metric `contribution` rule so it can be reused for
|
||||
// the totals query below. The totals query must rename percent-metric
|
||||
// columns the same way (`metric` -> `%metric`) so the footer can look them
|
||||
// up; without it the totals row renders 0.000%. We deliberately reuse only
|
||||
// this rule and not the full `postProcessing` array, which may also contain
|
||||
// a time-comparison operator that must not run on the single totals row.
|
||||
let contributionPostProcessing: PostProcessingRule | undefined;
|
||||
const nonCustomNorInheritShifts = ensureIsArray(
|
||||
formData.time_compare,
|
||||
).filter((shift: string) => shift !== 'custom' && shift !== 'inherit');
|
||||
@@ -157,15 +164,14 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
metrics.concat(percentMetrics),
|
||||
getMetricLabel,
|
||||
);
|
||||
postProcessing = [
|
||||
{
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: percentMetricLabels,
|
||||
rename_columns: percentMetricLabels.map(x => `%${x}`),
|
||||
},
|
||||
contributionPostProcessing = {
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: percentMetricLabels,
|
||||
rename_columns: percentMetricLabels.map(x => `%${x}`),
|
||||
},
|
||||
];
|
||||
};
|
||||
postProcessing = [contributionPostProcessing];
|
||||
}
|
||||
// Add the operator for the time comparison if some is selected
|
||||
if (!isEmpty(timeOffsets)) {
|
||||
@@ -658,7 +664,13 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
extras: totalsExtras, // Use extras with AG Grid WHERE removed
|
||||
row_limit: 0,
|
||||
row_offset: 0,
|
||||
post_processing: [],
|
||||
// Reapply only the percent-metric contribution rule so the totals row
|
||||
// exposes `%metric` keys (value/value = 100% on the single aggregated
|
||||
// row). The time-comparison operator from the main query is omitted on
|
||||
// purpose; it must not run against the single-row totals query.
|
||||
post_processing: contributionPostProcessing
|
||||
? [contributionPostProcessing]
|
||||
: [],
|
||||
order_desc: undefined, // we don't need orderby stuff here,
|
||||
orderby: undefined, // because this query will be used for get total aggregation.
|
||||
});
|
||||
|
||||
@@ -852,6 +852,75 @@ describe('plugin-chart-ag-grid-table', () => {
|
||||
expect(totalsQuery.columns).toEqual([]);
|
||||
expect(totalsQuery.row_limit).toBe(0);
|
||||
});
|
||||
|
||||
test('should reapply percent-metric contribution op to totals query', () => {
|
||||
// Regression test for #37627: when a percent metric is configured and
|
||||
// Show Summary (show_totals) is enabled, the totals query must rename
|
||||
// percent-metric columns (`metric` -> `%metric`) so the footer can
|
||||
// look them up. Otherwise the totals row renders 0.000%.
|
||||
const { queries } = buildQuery({
|
||||
...basicFormData,
|
||||
metrics: ['count'],
|
||||
percent_metrics: ['count'],
|
||||
show_totals: true,
|
||||
query_mode: QueryMode.Aggregate,
|
||||
});
|
||||
|
||||
// No server pagination -> queries[1] is the totals query.
|
||||
const totalsQuery = queries[1];
|
||||
const contributionRule = {
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: ['count'],
|
||||
rename_columns: ['%count'],
|
||||
},
|
||||
};
|
||||
|
||||
expect(queries[0].post_processing).toContainEqual(contributionRule);
|
||||
expect(totalsQuery.post_processing).toEqual([contributionRule]);
|
||||
});
|
||||
|
||||
test('should omit time-comparison op from totals post_processing', () => {
|
||||
// The totals query must reuse ONLY the contribution rule; the
|
||||
// time-comparison operator from the main query must not run against
|
||||
// the single-row totals query.
|
||||
const { queries } = buildQuery({
|
||||
...basicFormData,
|
||||
metrics: ['count'],
|
||||
percent_metrics: ['count'],
|
||||
show_totals: true,
|
||||
query_mode: QueryMode.Aggregate,
|
||||
time_compare: ['1 year ago'],
|
||||
comparison_type: 'values',
|
||||
});
|
||||
|
||||
const totalsQuery = queries[1];
|
||||
|
||||
// Exactly one op (contribution) — the time-comparison operator from the
|
||||
// main query must not be carried over to the single-row totals query.
|
||||
expect(totalsQuery.post_processing).toHaveLength(1);
|
||||
expect(totalsQuery.post_processing?.[0]).toMatchObject({
|
||||
operation: 'contribution',
|
||||
});
|
||||
// The reused rule matches the main query's contribution rule verbatim.
|
||||
expect(totalsQuery.post_processing?.[0]).toEqual(
|
||||
queries[0].post_processing?.find(
|
||||
op => op?.operation === 'contribution',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('should leave totals post_processing empty without percent metrics', () => {
|
||||
const { queries } = buildQuery({
|
||||
...basicFormData,
|
||||
metrics: ['count'],
|
||||
show_totals: true,
|
||||
query_mode: QueryMode.Aggregate,
|
||||
});
|
||||
|
||||
const totalsQuery = queries[1];
|
||||
expect(totalsQuery.post_processing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration - all filter types together', () => {
|
||||
|
||||
+50
@@ -231,6 +231,56 @@ describe('BigNumberTotal transformProps', () => {
|
||||
expect(result.headerFormatter(500)).toBe('$500');
|
||||
});
|
||||
|
||||
test('should pass through non-numeric raw string when parseMetricValue returns null (e.g. VARCHAR MAX)', () => {
|
||||
const { parseMetricValue } = jest.requireMock('../utils');
|
||||
parseMetricValue.mockReturnValueOnce(null);
|
||||
|
||||
const chartProps = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
queriesData: [
|
||||
{
|
||||
data: [{ value: 'some-varchar-result' }],
|
||||
coltypes: [GenericDataType.String],
|
||||
},
|
||||
],
|
||||
formData: baseFormData,
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberTotalChartProps,
|
||||
);
|
||||
expect(result.bigNumber).toBe('some-varchar-result');
|
||||
});
|
||||
|
||||
test('should pass through numeric-looking VARCHAR string literally (e.g. "123")', () => {
|
||||
const { parseMetricValue } = jest.requireMock('../utils');
|
||||
parseMetricValue.mockReturnValueOnce(null);
|
||||
|
||||
const chartProps = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
queriesData: [
|
||||
{
|
||||
data: [{ value: '123' }],
|
||||
coltypes: [GenericDataType.String],
|
||||
},
|
||||
],
|
||||
formData: baseFormData,
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberTotalChartProps,
|
||||
);
|
||||
expect(result.bigNumber).toBe('123');
|
||||
});
|
||||
|
||||
test('should propagate colorThresholdFormatters from getColorFormatters', () => {
|
||||
// Override the getColorFormatters mock to return specific value
|
||||
const mockFormatters = [{ formatter: 'red' }];
|
||||
|
||||
+8
-1
@@ -79,8 +79,15 @@ export default function transformProps(
|
||||
const formattedSubtitleFontSize = subtitle?.trim()
|
||||
? (subtitleFontSize ?? PROPORTION.SUBHEADER)
|
||||
: (subheaderFontSize ?? subtitleFontSize ?? PROPORTION.SUBHEADER);
|
||||
const rawValue = data.length === 0 ? null : data[0][metricName];
|
||||
const parsedValue = rawValue == null ? null : parseMetricValue(rawValue);
|
||||
|
||||
const bigNumber =
|
||||
data.length === 0 ? null : parseMetricValue(data[0][metricName]);
|
||||
parsedValue === null &&
|
||||
typeof rawValue === 'string' &&
|
||||
rawValue.trim() !== ''
|
||||
? rawValue
|
||||
: parsedValue;
|
||||
|
||||
let metricEntry: Metric | undefined;
|
||||
if (chartProps.datasource?.metrics) {
|
||||
|
||||
@@ -189,8 +189,10 @@ function BigNumberVis({
|
||||
text = t('No data');
|
||||
} else if (typeof bigNumber === 'number') {
|
||||
text = headerFormatter(bigNumber);
|
||||
} else if (typeof bigNumber === 'string') {
|
||||
text = bigNumber;
|
||||
} else {
|
||||
// For string/boolean/Date values, convert to number if possible, else show as string
|
||||
// For boolean/Date values, convert to number if possible, else show as string
|
||||
const numValue = Number(bigNumber);
|
||||
text = Number.isNaN(numValue)
|
||||
? String(bigNumber)
|
||||
|
||||
@@ -331,10 +331,16 @@ export default function transformProps(
|
||||
type: legendType,
|
||||
});
|
||||
|
||||
const chartPadding = getChartPadding(
|
||||
showLegend,
|
||||
legendOrientation,
|
||||
effectiveLegendMargin,
|
||||
);
|
||||
|
||||
const series: RadarSeriesOption[] = [
|
||||
{
|
||||
type: 'radar',
|
||||
...getChartPadding(showLegend, legendOrientation, effectiveLegendMargin),
|
||||
...chartPadding,
|
||||
animation: false,
|
||||
emphasis: {
|
||||
label: {
|
||||
@@ -361,6 +367,15 @@ export default function transformProps(
|
||||
numberFormatter,
|
||||
);
|
||||
|
||||
const centerX = width
|
||||
? ((width + chartPadding.left - chartPadding.right) / 2 / width) * 100
|
||||
: 50;
|
||||
const centerY = height
|
||||
? ((height + chartPadding.top - chartPadding.bottom) / 2 / height) * 100
|
||||
: 50;
|
||||
|
||||
const radarCenter: [string, string] = [`${centerX}%`, `${centerY}%`];
|
||||
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
grid: {
|
||||
...defaultGrid,
|
||||
@@ -390,6 +405,7 @@ export default function transformProps(
|
||||
color: theme.colorSplit,
|
||||
},
|
||||
},
|
||||
center: radarCenter,
|
||||
splitArea: {
|
||||
show: true,
|
||||
areaStyle: {
|
||||
|
||||
+162
-2
@@ -23,6 +23,7 @@ import {
|
||||
} from '../../../../spec/helpers/testing-library';
|
||||
import { AxisType } from '@superset-ui/core';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import type { ECElementEvent } from 'echarts/types/src/util/types';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
LegendOrientation,
|
||||
@@ -202,11 +203,15 @@ const defaultProps: TimeseriesChartTransformedProps = {
|
||||
onFocusedSeries: jest.fn(),
|
||||
};
|
||||
|
||||
function getLatestHeight() {
|
||||
function getLatestEchartProps() {
|
||||
const lastCall = mockEchart.mock.calls.at(-1);
|
||||
expect(lastCall).toBeDefined();
|
||||
const [props] = lastCall as [EchartsProps];
|
||||
return props.height;
|
||||
return props;
|
||||
}
|
||||
|
||||
function getLatestHeight() {
|
||||
return getLatestEchartProps().height;
|
||||
}
|
||||
|
||||
test('observes extra control height changes when ResizeObserver is available', async () => {
|
||||
@@ -335,6 +340,7 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
|
||||
const clickHandler = props.eventHandlers?.click;
|
||||
if (clickHandler) {
|
||||
clickHandler({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales', // This is the metric name
|
||||
data: ['Product A', 100], // X-axis value is 'Product A'
|
||||
name: 'Product A',
|
||||
@@ -361,6 +367,149 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
|
||||
}
|
||||
});
|
||||
|
||||
test('emits cross-filter on category value for horizontal bar clicks', async () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
}}
|
||||
xAxis={{
|
||||
label: 'category_column',
|
||||
type: AxisType.Category,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickHandler = getLatestEchartProps().eventHandlers?.click;
|
||||
expect(clickHandler).toBeDefined();
|
||||
clickHandler?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [100, 'Product A'],
|
||||
name: 'Product A',
|
||||
dataIndex: 0,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(setDataMaskMock).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'category_column',
|
||||
op: 'IN',
|
||||
val: ['Product A'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses rendered categorical axis for query event handlers', () => {
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
xAxis={{
|
||||
label: 'category_column',
|
||||
type: AxisType.Category,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
|
||||
'xAxis.category',
|
||||
);
|
||||
|
||||
cleanup();
|
||||
mockEchart.mockReset();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
}}
|
||||
xAxis={{
|
||||
label: 'category_column',
|
||||
type: AxisType.Category,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
|
||||
'yAxis.category',
|
||||
);
|
||||
});
|
||||
|
||||
test('emits cross-filter from horizontal categorical axis label clicks', () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
}}
|
||||
xAxis={{
|
||||
label: 'category_column',
|
||||
type: AxisType.Category,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const labelClickHandler =
|
||||
getLatestEchartProps().queryEventHandlers?.[0].handler;
|
||||
expect(labelClickHandler).toBeDefined();
|
||||
labelClickHandler?.({
|
||||
value: 'Product A',
|
||||
} as ECElementEvent);
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'category_column',
|
||||
op: 'IN',
|
||||
val: ['Product A'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not emit duplicate cross-filter for generic axis label clicks', async () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
xAxis={{
|
||||
label: 'category_column',
|
||||
type: AxisType.Category,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickHandler = getLatestEchartProps().eventHandlers?.click;
|
||||
expect(clickHandler).toBeDefined();
|
||||
clickHandler?.({
|
||||
componentType: 'xAxis',
|
||||
name: 'Product A',
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 400));
|
||||
expect(setDataMaskMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not emit cross-filter when no dimensions and time-based X-axis', async () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
@@ -385,6 +534,7 @@ test('does not emit cross-filter when no dimensions and time-based X-axis', asyn
|
||||
const clickHandler = props.eventHandlers?.click;
|
||||
if (clickHandler) {
|
||||
clickHandler({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [1609459200000, 100], // Timestamp
|
||||
name: '2021-01-01',
|
||||
@@ -407,6 +557,10 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
|
||||
...defaultProps,
|
||||
emitCrossFilters: true,
|
||||
setDataMask: setDataMaskMock,
|
||||
formData: {
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
},
|
||||
groupby: [], // No dimensions
|
||||
xAxis: {
|
||||
label: 'category_column',
|
||||
@@ -423,6 +577,7 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
|
||||
const clickHandler = props.eventHandlers?.click;
|
||||
if (clickHandler) {
|
||||
clickHandler({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales', // This is the metric name
|
||||
data: [100, 'Product A'], // Horizontal: value first, category second
|
||||
name: 'Product A',
|
||||
@@ -457,6 +612,10 @@ test('context menu cross-filter uses the category value for a horizontal categor
|
||||
...defaultProps,
|
||||
emitCrossFilters: true,
|
||||
onContextMenu: onContextMenuMock,
|
||||
formData: {
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
},
|
||||
groupby: [], // No dimensions
|
||||
xAxis: {
|
||||
label: 'category_column',
|
||||
@@ -474,6 +633,7 @@ test('context menu cross-filter uses the category value for a horizontal categor
|
||||
expect(contextMenuHandler).toBeDefined();
|
||||
if (contextMenuHandler) {
|
||||
await contextMenuHandler({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales', // This is the metric name
|
||||
data: [100, 'Product A'], // Horizontal: value first, category second
|
||||
name: 'Product A',
|
||||
|
||||
+74
-13
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
DTTM_ALIAS,
|
||||
BinaryQueryObjectFilterClause,
|
||||
@@ -27,12 +27,15 @@ import {
|
||||
LegendState,
|
||||
ensureIsArray,
|
||||
} from '@superset-ui/core';
|
||||
import type { ViewRootGroup } from 'echarts/types/src/util/types';
|
||||
import type {
|
||||
ECElementEvent,
|
||||
ViewRootGroup,
|
||||
} from 'echarts/types/src/util/types';
|
||||
import type GlobalModel from 'echarts/types/src/model/Global';
|
||||
import type ComponentModel from 'echarts/types/src/model/Component';
|
||||
import { EchartsHandler, EventHandlers } from '../types';
|
||||
import Echart from '../components/Echart';
|
||||
import { TimeseriesChartTransformedProps } from './types';
|
||||
import { OrientationType, TimeseriesChartTransformedProps } from './types';
|
||||
import { formatSeriesName } from '../utils/series';
|
||||
import { ExtraControls } from '../components/ExtraControls';
|
||||
|
||||
@@ -218,6 +221,26 @@ export default function EchartsTimeseries({
|
||||
// Determine if X-axis can be used for cross-filtering (categorical axis without dimensions)
|
||||
const canCrossFilterByXAxis =
|
||||
!hasDimensions && xAxis.type === AxisType.Category;
|
||||
const categoryAxisValueIndex =
|
||||
formData.orientation === OrientationType.Horizontal ? 1 : 0;
|
||||
const getCategoryAxisValue = useCallback(
|
||||
(data: unknown, name: unknown) => {
|
||||
if (Array.isArray(data)) {
|
||||
const categoryAxisValue = data[categoryAxisValueIndex];
|
||||
if (
|
||||
typeof categoryAxisValue === 'string' ||
|
||||
typeof categoryAxisValue === 'number'
|
||||
) {
|
||||
return categoryAxisValue;
|
||||
}
|
||||
}
|
||||
if (typeof name === 'string' || typeof name === 'number') {
|
||||
return name;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[categoryAxisValueIndex],
|
||||
);
|
||||
|
||||
const eventHandlers: EventHandlers = {
|
||||
click: props => {
|
||||
@@ -234,12 +257,15 @@ export default function EchartsTimeseries({
|
||||
// Cross-filter by dimension (original behavior)
|
||||
const { seriesName: name } = props;
|
||||
handleChange(name);
|
||||
} else if (canCrossFilterByXAxis && props.name != null) {
|
||||
// Cross-filter by X-axis value when no dimensions (issue #25334).
|
||||
// Use `name` (the category-axis value) instead of `data[0]`: for
|
||||
// horizontal bars the data tuple is value-first, so `data[0]` would
|
||||
// be the metric value rather than the category (issue #41102).
|
||||
handleXAxisChange(props.name);
|
||||
} else if (canCrossFilterByXAxis && props.componentType === 'series') {
|
||||
// Cross-filter by X-axis value when no dimensions (issue #25334)
|
||||
const categoryAxisValue = getCategoryAxisValue(
|
||||
props.data,
|
||||
props.name,
|
||||
);
|
||||
if (categoryAxisValue !== undefined) {
|
||||
handleXAxisChange(categoryAxisValue);
|
||||
}
|
||||
}
|
||||
}, TIMER_DURATION);
|
||||
},
|
||||
@@ -321,10 +347,17 @@ export default function EchartsTimeseries({
|
||||
let crossFilter;
|
||||
if (hasDimensions) {
|
||||
crossFilter = getCrossFilterDataMask(seriesName);
|
||||
} else if (canCrossFilterByXAxis && eventParams.name != null) {
|
||||
// Use `name` (the category-axis value), not `data[0]`, so horizontal
|
||||
// bars cross-filter on the category and not the metric (issue #41102).
|
||||
crossFilter = getXAxisCrossFilterDataMask(eventParams.name);
|
||||
} else if (
|
||||
canCrossFilterByXAxis &&
|
||||
eventParams.componentType === 'series'
|
||||
) {
|
||||
const categoryAxisValue = getCategoryAxisValue(
|
||||
data,
|
||||
eventParams.name,
|
||||
);
|
||||
if (categoryAxisValue !== undefined) {
|
||||
crossFilter = getXAxisCrossFilterDataMask(categoryAxisValue);
|
||||
}
|
||||
}
|
||||
|
||||
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
|
||||
@@ -336,6 +369,33 @@ export default function EchartsTimeseries({
|
||||
},
|
||||
};
|
||||
|
||||
const handleXAxisLabelClick = useCallback(
|
||||
(event: ECElementEvent) => {
|
||||
const { value } = event;
|
||||
if (
|
||||
canCrossFilterByXAxis &&
|
||||
(typeof value === 'string' || typeof value === 'number')
|
||||
) {
|
||||
handleXAxisChange(value);
|
||||
}
|
||||
},
|
||||
[canCrossFilterByXAxis, handleXAxisChange],
|
||||
);
|
||||
|
||||
const categoryAxis =
|
||||
formData.orientation === OrientationType.Horizontal ? 'yAxis' : 'xAxis';
|
||||
|
||||
const queryEventHandlers = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: 'click',
|
||||
query: `${categoryAxis}.category`,
|
||||
handler: handleXAxisLabelClick,
|
||||
},
|
||||
],
|
||||
[categoryAxis, handleXAxisLabelClick],
|
||||
);
|
||||
|
||||
const zrEventHandlers: EventHandlers = {
|
||||
dblclick: params => {
|
||||
// clear single click timer
|
||||
@@ -377,6 +437,7 @@ export default function EchartsTimeseries({
|
||||
width={width}
|
||||
echartOptions={echartOptions}
|
||||
eventHandlers={eventHandlers}
|
||||
queryEventHandlers={queryEventHandlers}
|
||||
zrEventHandlers={zrEventHandlers}
|
||||
selectedValues={selectedValues}
|
||||
vizType={formData.vizType}
|
||||
|
||||
@@ -889,6 +889,10 @@ export default function transformProps(
|
||||
name: xAxisTitle,
|
||||
nameGap: convertInteger(xAxisTitleMargin),
|
||||
nameLocation: 'middle',
|
||||
...(xAxisType === AxisType.Category &&
|
||||
groupBy.length === 0 && {
|
||||
triggerEvent: true,
|
||||
}),
|
||||
axisLabel: {
|
||||
// When rotation is applied on time axes, hideOverlap can
|
||||
// aggressively hide the last label. Rotated labels already
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, waitFor } from '../../../../spec/helpers/testing-library';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import Echart from './Echart';
|
||||
import type { EchartsProps } from '../types';
|
||||
|
||||
type Handler = (params: unknown) => void;
|
||||
type Listener = {
|
||||
query?: string;
|
||||
handler: Handler;
|
||||
};
|
||||
|
||||
const listeners: Record<string, Listener[]> = {};
|
||||
|
||||
const mockChart = {
|
||||
dispatchAction: jest.fn(),
|
||||
dispose: jest.fn(),
|
||||
getOption: jest.fn(() => ({})),
|
||||
getZr: jest.fn(() => ({
|
||||
off: jest.fn(),
|
||||
on: jest.fn(),
|
||||
})),
|
||||
off: jest.fn((name: string, handler?: Handler) => {
|
||||
if (!handler) {
|
||||
delete listeners[name];
|
||||
return;
|
||||
}
|
||||
listeners[name] = (listeners[name] || []).filter(
|
||||
listener => listener.handler !== handler,
|
||||
);
|
||||
}),
|
||||
on: jest.fn(
|
||||
(name: string, queryOrHandler: string | Handler, handler?: Handler) => {
|
||||
listeners[name] = listeners[name] || [];
|
||||
listeners[name].push(
|
||||
handler
|
||||
? { query: queryOrHandler as string, handler }
|
||||
: { handler: queryOrHandler as Handler },
|
||||
);
|
||||
},
|
||||
),
|
||||
resize: jest.fn(),
|
||||
setOption: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('echarts/core', () => ({
|
||||
init: jest.fn(() => mockChart),
|
||||
registerLocale: jest.fn(),
|
||||
use: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('echarts/charts', () => ({
|
||||
BarChart: 'BarChart',
|
||||
BoxplotChart: 'BoxplotChart',
|
||||
CustomChart: 'CustomChart',
|
||||
FunnelChart: 'FunnelChart',
|
||||
GaugeChart: 'GaugeChart',
|
||||
GraphChart: 'GraphChart',
|
||||
HeatmapChart: 'HeatmapChart',
|
||||
LineChart: 'LineChart',
|
||||
PieChart: 'PieChart',
|
||||
RadarChart: 'RadarChart',
|
||||
SankeyChart: 'SankeyChart',
|
||||
ScatterChart: 'ScatterChart',
|
||||
SunburstChart: 'SunburstChart',
|
||||
TreeChart: 'TreeChart',
|
||||
TreemapChart: 'TreemapChart',
|
||||
}));
|
||||
|
||||
jest.mock('echarts/components', () => ({
|
||||
AriaComponent: 'AriaComponent',
|
||||
DataZoomComponent: 'DataZoomComponent',
|
||||
GraphicComponent: 'GraphicComponent',
|
||||
GridComponent: 'GridComponent',
|
||||
LegendComponent: 'LegendComponent',
|
||||
MarkAreaComponent: 'MarkAreaComponent',
|
||||
MarkLineComponent: 'MarkLineComponent',
|
||||
TitleComponent: 'TitleComponent',
|
||||
ToolboxComponent: 'ToolboxComponent',
|
||||
TooltipComponent: 'TooltipComponent',
|
||||
VisualMapComponent: 'VisualMapComponent',
|
||||
}));
|
||||
|
||||
jest.mock('echarts/features', () => ({
|
||||
LabelLayout: 'LabelLayout',
|
||||
}));
|
||||
|
||||
jest.mock('echarts/renderers', () => ({
|
||||
CanvasRenderer: 'CanvasRenderer',
|
||||
}));
|
||||
|
||||
const initialState = {
|
||||
common: {
|
||||
locale: 'en',
|
||||
},
|
||||
dashboardState: {
|
||||
isRefreshing: false,
|
||||
},
|
||||
};
|
||||
|
||||
const defaultProps: EchartsProps = {
|
||||
echartOptions: { series: [] } as EChartsCoreOption,
|
||||
height: 100,
|
||||
refs: {},
|
||||
width: 100,
|
||||
};
|
||||
|
||||
const renderEchart = (props: Partial<EchartsProps> = {}) => (
|
||||
<Echart {...defaultProps} {...props} />
|
||||
);
|
||||
|
||||
const trigger = (name: string) => {
|
||||
(listeners[name] || []).forEach(listener => listener.handler({}));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.keys(listeners).forEach(name => {
|
||||
delete listeners[name];
|
||||
});
|
||||
Object.values(mockChart).forEach(value => {
|
||||
if (jest.isMockFunction(value)) {
|
||||
value.mockClear();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('replaces stale query event handlers without clearing regular event handlers', async () => {
|
||||
const regularClickHandler = jest.fn();
|
||||
const firstQueryHandler = jest.fn();
|
||||
const secondQueryHandler = jest.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
renderEchart({
|
||||
eventHandlers: {
|
||||
click: regularClickHandler,
|
||||
},
|
||||
queryEventHandlers: [
|
||||
{
|
||||
handler: firstQueryHandler,
|
||||
name: 'click',
|
||||
query: 'xAxis.category',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ initialState, useRedux: true },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockChart.on).toHaveBeenCalledWith(
|
||||
'click',
|
||||
'xAxis.category',
|
||||
firstQueryHandler,
|
||||
),
|
||||
);
|
||||
|
||||
rerender(
|
||||
renderEchart({
|
||||
eventHandlers: {
|
||||
click: regularClickHandler,
|
||||
},
|
||||
queryEventHandlers: [
|
||||
{
|
||||
handler: secondQueryHandler,
|
||||
name: 'click',
|
||||
query: 'xAxis.category',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockChart.on).toHaveBeenCalledWith(
|
||||
'click',
|
||||
'xAxis.category',
|
||||
secondQueryHandler,
|
||||
),
|
||||
);
|
||||
|
||||
trigger('click');
|
||||
|
||||
expect(regularClickHandler).toHaveBeenCalledTimes(1);
|
||||
expect(firstQueryHandler).not.toHaveBeenCalled();
|
||||
expect(secondQueryHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
regularClickHandler.mockClear();
|
||||
secondQueryHandler.mockClear();
|
||||
|
||||
rerender(
|
||||
renderEchart({
|
||||
eventHandlers: {
|
||||
click: regularClickHandler,
|
||||
},
|
||||
queryEventHandlers: [],
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockChart.off).toHaveBeenCalledWith('click', secondQueryHandler),
|
||||
);
|
||||
|
||||
trigger('click');
|
||||
|
||||
expect(regularClickHandler).toHaveBeenCalledTimes(1);
|
||||
expect(firstQueryHandler).not.toHaveBeenCalled();
|
||||
expect(secondQueryHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -64,7 +64,12 @@ import {
|
||||
MarkLineComponent,
|
||||
} from 'echarts/components';
|
||||
import { LabelLayout } from 'echarts/features';
|
||||
import { EchartsHandler, EchartsProps, EchartsStylesProps } from '../types';
|
||||
import {
|
||||
EchartsHandler,
|
||||
EchartsProps,
|
||||
EchartsStylesProps,
|
||||
QueryEventHandlers,
|
||||
} from '../types';
|
||||
import { DEFAULT_LOCALE } from '../constants';
|
||||
import { mergeEchartsThemeOverrides } from '../utils/themeOverrides';
|
||||
|
||||
@@ -132,6 +137,7 @@ function Echart(
|
||||
height,
|
||||
echartOptions,
|
||||
eventHandlers,
|
||||
queryEventHandlers,
|
||||
zrEventHandlers,
|
||||
selectedValues = {},
|
||||
refs,
|
||||
@@ -147,6 +153,7 @@ function Echart(
|
||||
}
|
||||
const [didMount, setDidMount] = useState(false);
|
||||
const chartRef = useRef<EChartsType>();
|
||||
const previousQueryEventHandlers = useRef<QueryEventHandlers>([]);
|
||||
const currentSelection = useMemo(
|
||||
() => Object.keys(selectedValues) || [],
|
||||
[selectedValues],
|
||||
@@ -196,11 +203,19 @@ function Echart(
|
||||
|
||||
useEffect(() => {
|
||||
if (didMount) {
|
||||
previousQueryEventHandlers.current.forEach(({ name, handler }) => {
|
||||
chartRef.current?.off(name, handler);
|
||||
});
|
||||
Object.entries(eventHandlers || {}).forEach(([name, handler]) => {
|
||||
chartRef.current?.off(name);
|
||||
chartRef.current?.on(name, handler);
|
||||
});
|
||||
|
||||
(queryEventHandlers || []).forEach(({ name, query, handler }) => {
|
||||
chartRef.current?.on(name, query, handler);
|
||||
});
|
||||
previousQueryEventHandlers.current = queryEventHandlers || [];
|
||||
|
||||
Object.entries(zrEventHandlers || {}).forEach(([name, handler]) => {
|
||||
chartRef.current?.getZr().off(name);
|
||||
chartRef.current?.getZr().on(name, handler);
|
||||
@@ -336,7 +351,15 @@ function Echart(
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- isDashboardRefreshing intentionally excluded to prevent extra setOption calls
|
||||
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme, vizType]);
|
||||
}, [
|
||||
didMount,
|
||||
echartOptions,
|
||||
eventHandlers,
|
||||
queryEventHandlers,
|
||||
zrEventHandlers,
|
||||
theme,
|
||||
vizType,
|
||||
]);
|
||||
|
||||
// Clear tooltip on refresh start to avoid stale content (#39247)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import type { EChartsCoreOption, EChartsType } from 'echarts/core';
|
||||
import type { TooltipMarker } from 'echarts/types/src/util/format';
|
||||
import type { ECElementEvent } from 'echarts/types/src/util/types';
|
||||
import { StackControlsValue } from './constants';
|
||||
|
||||
export type EchartsStylesProps = {
|
||||
@@ -51,6 +52,7 @@ export interface EchartsProps {
|
||||
width: number;
|
||||
echartOptions: EChartsCoreOption;
|
||||
eventHandlers?: EventHandlers;
|
||||
queryEventHandlers?: QueryEventHandlers;
|
||||
zrEventHandlers?: EventHandlers;
|
||||
selectedValues?: Record<number, string>;
|
||||
forceClear?: boolean;
|
||||
@@ -105,6 +107,12 @@ export type LegendFormData = {
|
||||
|
||||
export type EventHandlers = Record<string, { (props: any): void }>;
|
||||
|
||||
export type QueryEventHandlers = {
|
||||
name: string;
|
||||
query: string;
|
||||
handler: (props: ECElementEvent) => void;
|
||||
}[];
|
||||
|
||||
export enum LabelPositionEnum {
|
||||
Top = 'top',
|
||||
Left = 'left',
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
EchartsRadarChartProps,
|
||||
EchartsRadarFormData,
|
||||
} from '../../src/Radar/types';
|
||||
import { LegendOrientation } from '../../src/types';
|
||||
|
||||
interface RadarIndicator {
|
||||
name: string;
|
||||
@@ -202,3 +203,58 @@ describe('legend sorting', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('radar center positioning', () => {
|
||||
const getCenter = (overrides: Partial<EchartsRadarFormData> = {}) => {
|
||||
const props = new ChartProps({
|
||||
formData: {
|
||||
...formData,
|
||||
showLegend: true,
|
||||
legendMargin: 100,
|
||||
...overrides,
|
||||
},
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData,
|
||||
theme: supersetTheme,
|
||||
});
|
||||
const result = transformProps(props as EchartsRadarChartProps);
|
||||
const { center } = result.echartOptions.radar as {
|
||||
center: [string, string];
|
||||
};
|
||||
return {
|
||||
x: parseFloat(center[0]),
|
||||
y: parseFloat(center[1]),
|
||||
};
|
||||
};
|
||||
|
||||
test('keeps the center when the legend is hidden', () => {
|
||||
const { x, y } = getCenter({ showLegend: false });
|
||||
expect(x).toBe(50);
|
||||
expect(y).toBe(50);
|
||||
});
|
||||
|
||||
test('shifts the center right (away from the legend) when legend is on the left', () => {
|
||||
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Left });
|
||||
expect(x).toBeGreaterThan(50);
|
||||
expect(y).toBe(50);
|
||||
});
|
||||
|
||||
test('shifts the center left (away from the legend) when legend is on the right', () => {
|
||||
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Right });
|
||||
expect(x).toBeLessThan(50);
|
||||
expect(y).toBe(50);
|
||||
});
|
||||
|
||||
test('shifts the center down (away from the legend) when legend is on the top', () => {
|
||||
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Top });
|
||||
expect(x).toBe(50);
|
||||
expect(y).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
test('shifts the center up (away from the legend) when legend is on the bottom', () => {
|
||||
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Bottom });
|
||||
expect(x).toBe(50);
|
||||
expect(y).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
+5
-1
@@ -1564,9 +1564,13 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
|
||||
});
|
||||
|
||||
const { echartOptions } = transformProps(chartProps);
|
||||
const xAxis = echartOptions.xAxis as { type: string };
|
||||
const xAxis = echartOptions.xAxis as {
|
||||
triggerEvent?: boolean;
|
||||
type: string;
|
||||
};
|
||||
|
||||
expect(xAxis.type).toBe(AxisType.Category);
|
||||
expect(xAxis.triggerEvent).toBe(true);
|
||||
});
|
||||
|
||||
test('temporal x coltype wires the time formatter and Time axis', () => {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.24.1",
|
||||
"mapbox-gl": "^3.25.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.1",
|
||||
"supercluster": "^8.0.1"
|
||||
|
||||
@@ -86,6 +86,13 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
let { metrics, orderby = [], columns = [] } = baseQueryObject;
|
||||
const { extras = {} } = baseQueryObject;
|
||||
const postProcessing: PostProcessingRule[] = [];
|
||||
// Capture the percent-metric `contribution` rule so it can be reused for
|
||||
// the totals query below. Without it the totals row's percent-metric
|
||||
// columns are keyed `metric` instead of `%metric`, so the footer renders
|
||||
// 0.000%. We reuse only this rule and not the full `postProcessing` array,
|
||||
// which may also contain a time-comparison operator that must not run on
|
||||
// the single totals row.
|
||||
let contributionPostProcessing: PostProcessingRule | undefined;
|
||||
const nonCustomNorInheritShifts = ensureIsArray(
|
||||
formData.time_compare,
|
||||
).filter((shift: string) => shift !== 'custom' && shift !== 'inherit');
|
||||
@@ -137,12 +144,6 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
orderby = [[metrics[0], false]];
|
||||
}
|
||||
// add postprocessing for percent metrics only when in aggregation mode
|
||||
type PercentMetricCalculationMode = 'row_limit' | 'all_records';
|
||||
|
||||
const calculationMode: PercentMetricCalculationMode =
|
||||
(formData.percent_metric_calculation as PercentMetricCalculationMode) ||
|
||||
'row_limit';
|
||||
|
||||
if (percentMetrics && percentMetrics.length > 0) {
|
||||
const percentMetricsLabelsWithTimeComparison = isTimeComparison(
|
||||
formData,
|
||||
@@ -162,23 +163,14 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
getMetricLabel,
|
||||
);
|
||||
|
||||
if (calculationMode === 'all_records') {
|
||||
postProcessing.push({
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: percentMetricLabels,
|
||||
rename_columns: percentMetricLabels.map(m => `%${m}`),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
postProcessing.push({
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: percentMetricLabels,
|
||||
rename_columns: percentMetricLabels.map(m => `%${m}`),
|
||||
},
|
||||
});
|
||||
}
|
||||
contributionPostProcessing = {
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: percentMetricLabels,
|
||||
rename_columns: percentMetricLabels.map(m => `%${m}`),
|
||||
},
|
||||
};
|
||||
postProcessing.push(contributionPostProcessing);
|
||||
}
|
||||
|
||||
// Add the operator for the time comparison if some is selected
|
||||
@@ -357,7 +349,13 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
columns: [],
|
||||
row_limit: 0,
|
||||
row_offset: 0,
|
||||
post_processing: [],
|
||||
// Reapply only the percent-metric contribution rule so the totals row
|
||||
// exposes `%metric` keys (value/value = 100% on the single aggregated
|
||||
// row). The time-comparison operator from the main query is omitted on
|
||||
// purpose; it must not run against the single-row totals query.
|
||||
post_processing: contributionPostProcessing
|
||||
? [contributionPostProcessing]
|
||||
: [],
|
||||
order_desc: undefined,
|
||||
orderby: undefined,
|
||||
});
|
||||
|
||||
@@ -236,6 +236,83 @@ describe('plugin-chart-table', () => {
|
||||
expect(queries).toHaveLength(1);
|
||||
expect(queries[0].post_processing).toEqual([]);
|
||||
});
|
||||
|
||||
test('should reapply contribution op to totals query in row_limit mode', () => {
|
||||
// Regression test for #37627: with a percent metric and Show Summary
|
||||
// (show_totals) enabled, the totals query must rename percent-metric
|
||||
// columns (`metric` -> `%metric`) so the footer can look them up.
|
||||
// Otherwise the totals row renders 0.000%.
|
||||
const formData = {
|
||||
...baseFormDataWithPercents,
|
||||
show_totals: true,
|
||||
};
|
||||
|
||||
const { queries } = buildQuery(formData);
|
||||
|
||||
// row_limit mode + show_totals -> [main, totals].
|
||||
expect(queries).toHaveLength(2);
|
||||
|
||||
const contributionRule = {
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: ['sum_sales'],
|
||||
rename_columns: ['%sum_sales'],
|
||||
},
|
||||
};
|
||||
|
||||
expect(queries[1]).toMatchObject({
|
||||
columns: [],
|
||||
post_processing: [contributionRule],
|
||||
});
|
||||
});
|
||||
|
||||
test('should omit time-comparison op from totals post_processing', () => {
|
||||
// The totals query must reuse ONLY the contribution rule; the
|
||||
// time-comparison operator from the main query must not run against
|
||||
// the single-row totals query.
|
||||
const formData = {
|
||||
...baseFormDataWithPercents,
|
||||
show_totals: true,
|
||||
time_compare: ['1 year ago'],
|
||||
comparison_type: 'values',
|
||||
};
|
||||
|
||||
const { queries } = buildQuery(formData);
|
||||
|
||||
// row_limit mode + show_totals -> [main, totals].
|
||||
expect(queries).toHaveLength(2);
|
||||
|
||||
const totalsQuery = queries[1];
|
||||
|
||||
// Exactly one op (contribution) — the time-comparison operator from the
|
||||
// main query must not be carried over to the single-row totals query.
|
||||
expect(totalsQuery.post_processing).toHaveLength(1);
|
||||
expect(totalsQuery.post_processing?.[0]).toMatchObject({
|
||||
operation: 'contribution',
|
||||
});
|
||||
// The reused rule matches the main query's contribution rule verbatim.
|
||||
expect(totalsQuery.post_processing?.[0]).toEqual(
|
||||
queries[0].post_processing?.find(
|
||||
op => op?.operation === 'contribution',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('should leave totals post_processing empty without percent metrics', () => {
|
||||
const formData = {
|
||||
...basicFormData,
|
||||
query_mode: QueryMode.Aggregate,
|
||||
metrics: ['count'],
|
||||
percent_metrics: [],
|
||||
groupby: ['category'],
|
||||
show_totals: true,
|
||||
};
|
||||
|
||||
const { queries } = buildQuery(formData);
|
||||
|
||||
expect(queries).toHaveLength(2);
|
||||
expect(queries[1].post_processing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testing for server pagination with search filter', () => {
|
||||
|
||||
@@ -632,6 +632,35 @@ function processFile(filepath) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Application source trees that must be authored in TypeScript. Matches the
|
||||
* top-level `src/` directory as well as each package/plugin `src/` directory.
|
||||
*/
|
||||
const TS_ONLY_SOURCE_PATTERN =
|
||||
/^(src|packages\/[^/]+\/src|plugins\/[^/]+\/src)\//;
|
||||
|
||||
/**
|
||||
* Enforce the TypeScript-only frontend convention: no `.js`/`.jsx` files may be
|
||||
* added under the application source trees (including test files). Build
|
||||
* artifacts and root-level config files (e.g. `.storybook/preview.jsx`,
|
||||
* `webpack.config.js`) live outside these trees and are intentionally allowed.
|
||||
*
|
||||
* @param {string[]} candidateFiles paths relative to `superset-frontend/`
|
||||
*/
|
||||
function checkTypeScriptOnlySource(candidateFiles) {
|
||||
candidateFiles.forEach(file => {
|
||||
if (TS_ONLY_SOURCE_PATTERN.test(file) && /\.(js|jsx)$/.test(file)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`${RED}✗${RESET} ${file}: frontend source must be TypeScript. ` +
|
||||
`Rename to .ts/.tsx (the codebase is mid-migration to full ` +
|
||||
`TypeScript; no new .js/.jsx files in src/).`,
|
||||
);
|
||||
errorCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
@@ -666,6 +695,22 @@ function main() {
|
||||
/packages\/superset-ui-core\/src\/color\/index\.ts/, // Core brand color constants
|
||||
];
|
||||
|
||||
// Enforce TypeScript-only source. Run this on the raw file list (before the
|
||||
// ignore patterns below strip out tests/stories) so that e.g. a new
|
||||
// `*.test.jsx` is still rejected.
|
||||
const tsOnlyCandidates =
|
||||
args.length === 0
|
||||
? glob.sync('{src,packages/*/src,plugins/*/src}/**/*.{js,jsx}', {
|
||||
ignore: [
|
||||
'**/node_modules/**',
|
||||
'**/esm/**',
|
||||
'**/lib/**',
|
||||
'**/dist/**',
|
||||
],
|
||||
})
|
||||
: args.map(f => f.replace(/^superset-frontend\//, ''));
|
||||
checkTypeScriptOnlySource(tsOnlyCandidates);
|
||||
|
||||
// If no files specified, check all
|
||||
if (files.length === 0) {
|
||||
files = glob.sync('src/**/*.{ts,tsx,js,jsx}', {
|
||||
@@ -706,22 +751,23 @@ function main() {
|
||||
if (files.length === 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('No files to check.');
|
||||
return;
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`Checking ${files.length} files for Superset custom rules...\n`,
|
||||
);
|
||||
|
||||
files.forEach(file => {
|
||||
// Resolve the file path
|
||||
const resolvedPath = path.resolve(file);
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
processFile(resolvedPath);
|
||||
} else if (fs.existsSync(file)) {
|
||||
processFile(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Checking ${files.length} files for Superset custom rules...\n`);
|
||||
|
||||
files.forEach(file => {
|
||||
// Resolve the file path
|
||||
const resolvedPath = path.resolve(file);
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
processFile(resolvedPath);
|
||||
} else if (fs.existsSync(file)) {
|
||||
processFile(file);
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`\n${errorCount} errors, ${warningCount} warnings`);
|
||||
|
||||
@@ -740,4 +786,5 @@ module.exports = {
|
||||
checkNoFaIcons,
|
||||
checkI18nTemplates,
|
||||
checkUntranslatedStrings,
|
||||
checkTypeScriptOnlySource,
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { Column, GridApi } from 'ag-grid-community';
|
||||
import type { Column, GridApi, IHeaderParams } from 'ag-grid-community';
|
||||
import { act, fireEvent, render } from 'spec/helpers/testing-library';
|
||||
import { Header } from './Header';
|
||||
import { PIVOT_COL_ID } from './constants';
|
||||
@@ -38,9 +38,70 @@ jest.mock('@superset-ui/core/components/Icons', () => {
|
||||
};
|
||||
});
|
||||
|
||||
class MockApi extends EventTarget {
|
||||
class MockColumn {
|
||||
private colListeners = new Map<string, Set<Function>>();
|
||||
|
||||
sortValue: string | null = 'asc';
|
||||
|
||||
sortIndexValue: number | null = null;
|
||||
|
||||
getColId() {
|
||||
return '123';
|
||||
}
|
||||
|
||||
isPinnedLeft() {
|
||||
return true;
|
||||
}
|
||||
|
||||
isPinnedRight() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isVisible() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getSort() {
|
||||
return this.sortValue;
|
||||
}
|
||||
|
||||
getSortIndex() {
|
||||
return this.sortIndexValue;
|
||||
}
|
||||
|
||||
addEventListener(eventType: string, listener: Function) {
|
||||
if (!this.colListeners.has(eventType)) {
|
||||
this.colListeners.set(eventType, new Set());
|
||||
}
|
||||
this.colListeners.get(eventType)!.add(listener);
|
||||
}
|
||||
|
||||
removeEventListener(eventType: string, listener: Function) {
|
||||
this.colListeners.get(eventType)?.delete(listener);
|
||||
}
|
||||
|
||||
triggerEvent(eventType: string) {
|
||||
this.colListeners.get(eventType)?.forEach(listener => listener({}));
|
||||
}
|
||||
}
|
||||
|
||||
class MockOtherColumn extends MockColumn {
|
||||
getColId() {
|
||||
return 'other-col';
|
||||
}
|
||||
}
|
||||
|
||||
class MockApi {
|
||||
mockColumn = new MockColumn();
|
||||
|
||||
otherColumn = new MockOtherColumn();
|
||||
|
||||
getAllDisplayedColumns() {
|
||||
return [];
|
||||
return [this.mockColumn, this.otherColumn];
|
||||
}
|
||||
|
||||
getColumns() {
|
||||
return [this.mockColumn, this.otherColumn];
|
||||
}
|
||||
|
||||
isDestroyed() {
|
||||
@@ -48,48 +109,76 @@ class MockApi extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
const mockApi = new MockApi();
|
||||
|
||||
const mockedProps = {
|
||||
displayName: 'test column',
|
||||
setSort: jest.fn(),
|
||||
progressSort: jest.fn(),
|
||||
enableSorting: true,
|
||||
column: {
|
||||
getColId: () => '123',
|
||||
isPinnedLeft: () => true,
|
||||
isPinnedRight: () => false,
|
||||
getSort: () => 'asc',
|
||||
getSortIndex: () => null,
|
||||
} as any as Column,
|
||||
api: new MockApi() as any as GridApi,
|
||||
};
|
||||
column: mockApi.mockColumn as any as Column,
|
||||
api: mockApi as any as GridApi,
|
||||
} as unknown as IHeaderParams;
|
||||
|
||||
test('renders display name for the column', () => {
|
||||
const { queryByText } = render(<Header {...mockedProps} />);
|
||||
expect(queryByText(mockedProps.displayName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('sorts by clicking a column header', () => {
|
||||
const { getByText, queryByTestId } = render(<Header {...mockedProps} />);
|
||||
test('calls progressSort without shiftKey on click', () => {
|
||||
const { getByText } = render(<Header {...mockedProps} />);
|
||||
fireEvent.click(getByText(mockedProps.displayName));
|
||||
expect(mockedProps.setSort).toHaveBeenCalledWith('asc', false);
|
||||
expect(queryByTestId('mock-sort-asc')).toBeInTheDocument();
|
||||
fireEvent.click(getByText(mockedProps.displayName));
|
||||
expect(mockedProps.setSort).toHaveBeenCalledWith('desc', false);
|
||||
expect(queryByTestId('mock-sort-desc')).toBeInTheDocument();
|
||||
fireEvent.click(getByText(mockedProps.displayName));
|
||||
expect(mockedProps.setSort).toHaveBeenCalledWith(null, false);
|
||||
expect(queryByTestId('mock-sort-asc')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('mock-sort-desc')).not.toBeInTheDocument();
|
||||
expect(mockedProps.progressSort).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('synchronizes the current sort when sortChanged event occurred', async () => {
|
||||
const { findByTestId } = render(<Header {...mockedProps} />);
|
||||
test('calls progressSort with shiftKey on shift-click', () => {
|
||||
const { getByText } = render(<Header {...mockedProps} />);
|
||||
fireEvent.click(getByText(mockedProps.displayName), { shiftKey: true });
|
||||
expect(mockedProps.progressSort).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('synchronizes sort icon when columnStateUpdated fires on column', async () => {
|
||||
const { findByTestId, queryByTestId } = render(<Header {...mockedProps} />);
|
||||
expect(queryByTestId('mock-sort-asc')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
mockedProps.api.dispatchEvent(new Event('sortChanged'));
|
||||
mockApi.mockColumn.triggerEvent('columnStateUpdated');
|
||||
});
|
||||
|
||||
const sortAsc = await findByTestId('mock-sort-asc');
|
||||
expect(sortAsc).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows sortIndex label when multi-sort is active', async () => {
|
||||
const { findByText } = render(<Header {...mockedProps} />);
|
||||
|
||||
act(() => {
|
||||
mockApi.mockColumn.sortIndexValue = 1;
|
||||
mockApi.otherColumn.sortValue = 'desc';
|
||||
mockApi.mockColumn.triggerEvent('columnStateUpdated');
|
||||
});
|
||||
|
||||
const label = await findByText('2');
|
||||
expect(label).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides sortIndex label when multi-sort is cleared', async () => {
|
||||
const { queryByText } = render(<Header {...mockedProps} />);
|
||||
|
||||
act(() => {
|
||||
mockApi.mockColumn.sortIndexValue = 1;
|
||||
mockApi.otherColumn.sortValue = 'desc';
|
||||
mockApi.mockColumn.triggerEvent('columnStateUpdated');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
mockApi.mockColumn.sortIndexValue = null;
|
||||
mockApi.otherColumn.sortValue = null;
|
||||
mockApi.mockColumn.triggerEvent('columnStateUpdated');
|
||||
});
|
||||
|
||||
expect(queryByText('2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('disable menu when enableFilterButton is false', () => {
|
||||
const { queryByText, queryByTestId } = render(
|
||||
<Header {...mockedProps} enableFilterButton={false} />,
|
||||
@@ -99,18 +188,39 @@ test('disable menu when enableFilterButton is false', () => {
|
||||
});
|
||||
|
||||
test('hide display name for PIVOT_COL_ID', () => {
|
||||
const pivotColumn = new MockColumn();
|
||||
(pivotColumn as any).getColId = () => PIVOT_COL_ID;
|
||||
|
||||
const { queryByText } = render(
|
||||
<Header
|
||||
{...mockedProps}
|
||||
column={
|
||||
{
|
||||
getColId: () => PIVOT_COL_ID,
|
||||
isPinnedLeft: () => true,
|
||||
isPinnedRight: () => false,
|
||||
getSortIndex: () => null,
|
||||
} as any as Column
|
||||
}
|
||||
/>,
|
||||
<Header {...mockedProps} column={pivotColumn as any as Column} />,
|
||||
);
|
||||
expect(queryByText(mockedProps.displayName)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not attach click handler when enableSorting is false', () => {
|
||||
const { getByText } = render(
|
||||
<Header {...mockedProps} enableSorting={false} />,
|
||||
);
|
||||
const cell = getByText(mockedProps.displayName).closest(
|
||||
'.ag-header-cell-label',
|
||||
);
|
||||
expect(cell).not.toHaveAttribute('role', 'button');
|
||||
});
|
||||
|
||||
test('does not call progressSort on click when enableSorting is false', () => {
|
||||
const progressSort = jest.fn();
|
||||
const { getByText } = render(
|
||||
<Header {...mockedProps} enableSorting={false} progressSort={progressSort} />,
|
||||
);
|
||||
fireEvent.click(getByText(mockedProps.displayName));
|
||||
expect(progressSort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not render sort icons when enableSorting is false', () => {
|
||||
const { queryByTestId } = render(
|
||||
<Header {...mockedProps} enableSorting={false} />,
|
||||
);
|
||||
expect(queryByTestId('mock-sort')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('mock-sort-asc')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('mock-sort-desc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -16,32 +16,16 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { IHeaderParams, Column, SortDirection } from 'ag-grid-community';
|
||||
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled, useTheme } from '@apache-superset/core/theme';
|
||||
import type { Column, GridApi } from 'ag-grid-community';
|
||||
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
|
||||
import { PIVOT_COL_ID } from './constants';
|
||||
import { HeaderMenu } from './HeaderMenu';
|
||||
|
||||
interface Params {
|
||||
enableFilterButton?: boolean;
|
||||
enableSorting?: boolean;
|
||||
displayName: string;
|
||||
column: Column;
|
||||
api: GridApi;
|
||||
setSort: (sort: string | null, multiSort: boolean) => void;
|
||||
}
|
||||
|
||||
const SORT_DIRECTION = [null, 'asc', 'desc'];
|
||||
|
||||
const HeaderCell = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -87,30 +71,26 @@ const IconPlaceholder = styled.div`
|
||||
top: 0;
|
||||
`;
|
||||
|
||||
export const Header: React.FC<Params> = ({
|
||||
export const Header: React.FC<IHeaderParams> = ({
|
||||
enableFilterButton,
|
||||
enableSorting,
|
||||
displayName,
|
||||
setSort,
|
||||
progressSort,
|
||||
column,
|
||||
api,
|
||||
}: Params) => {
|
||||
}: IHeaderParams) => {
|
||||
const theme = useTheme();
|
||||
const colId = column.getColId();
|
||||
const pinnedLeft = column.isPinnedLeft();
|
||||
const pinnedRight = column.isPinnedRight();
|
||||
const sortOption = useRef<number>(0);
|
||||
const [invisibleColumns, setInvisibleColumns] = useState<Column[]>([]);
|
||||
const [currentSort, setCurrentSort] = useState<string | null>(null);
|
||||
const [currentSort, setCurrentSort] = useState<SortDirection>(null);
|
||||
const [sortIndex, setSortIndex] = useState<number | null>();
|
||||
const onSort = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
sortOption.current = (sortOption.current + 1) % SORT_DIRECTION.length;
|
||||
const sort = SORT_DIRECTION[sortOption.current];
|
||||
setSort(sort, event.shiftKey);
|
||||
setCurrentSort(sort);
|
||||
(event: React.MouseEvent) => {
|
||||
progressSort(event.shiftKey);
|
||||
},
|
||||
[setSort],
|
||||
[progressSort],
|
||||
);
|
||||
const onVisibleChange = useCallback(
|
||||
(isVisible: boolean) => {
|
||||
@@ -123,24 +103,22 @@ export const Header: React.FC<Params> = ({
|
||||
[api],
|
||||
);
|
||||
|
||||
const onSortChanged = useCallback(() => {
|
||||
const syncSortState = useCallback(() => {
|
||||
const hasMultiSort = api
|
||||
.getAllDisplayedColumns()
|
||||
.some(c => c.getSortIndex());
|
||||
const updatedSortIndex = column.getSortIndex();
|
||||
sortOption.current = SORT_DIRECTION.indexOf(column.getSort() ?? null);
|
||||
.some(c => c.getColId() !== colId && c.getSort() !== null);
|
||||
setCurrentSort(column.getSort() ?? null);
|
||||
setSortIndex(hasMultiSort ? updatedSortIndex : null);
|
||||
}, [api, column]);
|
||||
setSortIndex(hasMultiSort ? column.getSortIndex() : null);
|
||||
}, [api, column, colId]);
|
||||
|
||||
useEffect(() => {
|
||||
api.addEventListener('sortChanged', onSortChanged);
|
||||
column.addEventListener('columnStateUpdated', syncSortState);
|
||||
|
||||
return () => {
|
||||
if (api.isDestroyed()) return;
|
||||
api.removeEventListener('sortChanged', onSortChanged);
|
||||
column.removeEventListener('columnStateUpdated', syncSortState);
|
||||
};
|
||||
}, [api, onSortChanged]);
|
||||
}, [column, syncSortState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -379,6 +379,79 @@ test('should fallback to formData state when runtime state not available', () =>
|
||||
expect(getByTestId('chart-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('chart height is reduced on first render in expanded state (guards against useEffect regression)', () => {
|
||||
const DESCRIPTION_HEIGHT = 60;
|
||||
const CHART_HEIGHT = 300;
|
||||
// Matches the DEFAULT_HEADER_HEIGHT constant in Chart.tsx.
|
||||
const DEFAULT_HEADER_HEIGHT = 22;
|
||||
|
||||
// Stabilise getHeaderHeight(): emotion injects margin-bottom CSS during
|
||||
// React's commit phase, so getComputedStyle returns different values in
|
||||
// initial renders vs re-renders. Mock it to always return empty so
|
||||
// getHeaderHeight() consistently falls back to DEFAULT_HEADER_HEIGHT.
|
||||
const getComputedStyleSpy = jest
|
||||
.spyOn(window, 'getComputedStyle')
|
||||
.mockReturnValue({
|
||||
getPropertyValue: () => '',
|
||||
} as unknown as CSSStyleDeclaration);
|
||||
|
||||
// JSDOM doesn't compute layout, so mock offsetHeight to simulate a real
|
||||
// description element with height.
|
||||
const offsetHeightSpy = jest
|
||||
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
|
||||
.mockImplementation(function (this: HTMLElement) {
|
||||
return this.classList.contains('slice_description')
|
||||
? DESCRIPTION_HEIGHT
|
||||
: 0;
|
||||
});
|
||||
|
||||
// Suppress all passive effects to simulate the first-paint moment — the
|
||||
// point at which the original useEffect bug caused clipping. useLayoutEffect
|
||||
// (the fix) runs synchronously before paint and is intentionally NOT mocked
|
||||
// here. If the implementation were reverted to useEffect, this spy would
|
||||
// prevent the height measurement and the assertion below would fail.
|
||||
const useEffectSpy = jest
|
||||
.spyOn(global.React, 'useEffect')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const { container } = setup(
|
||||
{ height: CHART_HEIGHT },
|
||||
{
|
||||
charts: {
|
||||
...defaultState.charts,
|
||||
[queryId]: {
|
||||
...defaultState.charts[queryId],
|
||||
// ChartOverlay renders with an inline height style when loading —
|
||||
// this is the observable proxy for getChartHeight() without real layout.
|
||||
chartStatus: 'loading',
|
||||
},
|
||||
},
|
||||
dashboardState: {
|
||||
...defaultState.dashboardState,
|
||||
expandedSlices: { [queryId]: true },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const chartHeight = parseInt(
|
||||
container.querySelector<HTMLDivElement>('.dashboard-chart > div[style]')!
|
||||
.style.height,
|
||||
10,
|
||||
);
|
||||
|
||||
// useLayoutEffect must have measured and applied descriptionHeight
|
||||
// synchronously. If useEffect were used instead, descriptionHeight would
|
||||
// still be 0 here (suppressed by useEffectSpy) and chartHeight would equal
|
||||
// CHART_HEIGHT - DEFAULT_HEADER_HEIGHT rather than the value below.
|
||||
expect(chartHeight).toBe(
|
||||
CHART_HEIGHT - DEFAULT_HEADER_HEIGHT - DESCRIPTION_HEIGHT,
|
||||
);
|
||||
|
||||
useEffectSpy.mockRestore();
|
||||
getComputedStyleSpy.mockRestore();
|
||||
offsetHeightSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should not show a close button on chart error banners', () => {
|
||||
const { queryByRole } = setup(
|
||||
{},
|
||||
|
||||
@@ -20,6 +20,7 @@ import cx from 'classnames';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
useState,
|
||||
@@ -318,13 +319,9 @@ const Chart = (props: ChartProps) => {
|
||||
[dispatch, props.id, sliceVizType],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
const descHeight =
|
||||
isExpanded && descriptionRef.current
|
||||
? descriptionRef.current?.offsetHeight
|
||||
: 0;
|
||||
setDescriptionHeight(descHeight);
|
||||
useLayoutEffect(() => {
|
||||
if (isExpanded && descriptionRef.current) {
|
||||
setDescriptionHeight(descriptionRef.current.offsetHeight);
|
||||
} else {
|
||||
setDescriptionHeight(0);
|
||||
}
|
||||
@@ -484,7 +481,7 @@ const Chart = (props: ChartProps) => {
|
||||
(formData as JsonObject).dashboardId = dashboardInfo.id;
|
||||
|
||||
const exportTable = useCallback(
|
||||
(format: string, isFullCSV: boolean, isPivot = false) => {
|
||||
async (format: string, isFullCSV: boolean, isPivot = false) => {
|
||||
const logAction =
|
||||
format === 'csv'
|
||||
? LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART
|
||||
@@ -559,24 +556,48 @@ const Chart = (props: ChartProps) => {
|
||||
}
|
||||
: baseOwnState;
|
||||
|
||||
exportChart({
|
||||
formData:
|
||||
exportFormData as unknown as import('@superset-ui/core').QueryFormData,
|
||||
resultType,
|
||||
resultFormat: format,
|
||||
force: true,
|
||||
ownState: exportOwnState,
|
||||
onStartStreamingExport: shouldUseStreaming
|
||||
? (exportParams: JsonObject) => {
|
||||
setIsStreamingModalVisible(true);
|
||||
startExport({
|
||||
...(exportParams as Record<string, unknown>),
|
||||
filename,
|
||||
expectedRows: actualRowCount,
|
||||
} as Parameters<typeof startExport>[0]);
|
||||
}
|
||||
: null,
|
||||
});
|
||||
try {
|
||||
await exportChart({
|
||||
formData:
|
||||
exportFormData as unknown as import('@superset-ui/core').QueryFormData,
|
||||
resultType,
|
||||
resultFormat: format,
|
||||
force: true,
|
||||
ownState: exportOwnState,
|
||||
onStartStreamingExport: shouldUseStreaming
|
||||
? (exportParams: JsonObject) => {
|
||||
setIsStreamingModalVisible(true);
|
||||
startExport({
|
||||
...(exportParams as Record<string, unknown>),
|
||||
filename,
|
||||
expectedRows: actualRowCount,
|
||||
} as Parameters<typeof startExport>[0]);
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const exportError = error as Error & {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
response?: { status?: number };
|
||||
};
|
||||
const status = exportError.status || exportError.response?.status;
|
||||
if (status === 413) {
|
||||
boundActionCreators.addDangerToast(
|
||||
t(
|
||||
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const errorMessage =
|
||||
exportError.message ||
|
||||
exportError.statusText ||
|
||||
t(
|
||||
'Failed to export chart data. Please try again or contact your administrator.',
|
||||
);
|
||||
boundActionCreators.addDangerToast(errorMessage);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
sliceSliceId,
|
||||
@@ -588,6 +609,7 @@ const Chart = (props: ChartProps) => {
|
||||
chartState,
|
||||
props.id,
|
||||
boundActionCreators.logEvent,
|
||||
boundActionCreators.addDangerToast,
|
||||
queriesResponse,
|
||||
startExport,
|
||||
resetExport,
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
extractLabel,
|
||||
getAppliedColumnsWithFallback,
|
||||
getCrossFilterIndicator,
|
||||
IndicatorStatus,
|
||||
selectNativeIndicatorsForChart,
|
||||
} from './selectors';
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
@@ -207,6 +209,21 @@ test('getAppliedColumnsWithFallback returns columns from query response when ava
|
||||
expect(result).toEqual(new Set(['age', 'name']));
|
||||
});
|
||||
|
||||
test('getAppliedColumnsWithFallback returns columns from all query responses', () => {
|
||||
const chart = {
|
||||
queriesResponse: [
|
||||
{
|
||||
applied_filters: [],
|
||||
},
|
||||
{
|
||||
applied_filters: [{ column: 'age' }, { column: 'name' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = getAppliedColumnsWithFallback(chart);
|
||||
expect(result).toEqual(new Set(['age', 'name']));
|
||||
});
|
||||
|
||||
test('getAppliedColumnsWithFallback returns empty set when query response has no applied_filters and no fallback params', () => {
|
||||
const chart = {
|
||||
queriesResponse: [{ applied_filters: [] }],
|
||||
@@ -565,3 +582,47 @@ test('getAppliedColumnsWithFallback prioritizes query response over fallback', (
|
||||
);
|
||||
expect(result).toEqual(new Set(['query_column']));
|
||||
});
|
||||
|
||||
test('selectNativeIndicatorsForChart marks rejected filters from later query responses incompatible', () => {
|
||||
const chartId = 987;
|
||||
const nativeFilters = {
|
||||
filter1: {
|
||||
id: 'filter1',
|
||||
name: 'Age',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [chartId],
|
||||
targets: [{ column: { name: 'age' } }],
|
||||
},
|
||||
} as any;
|
||||
const dataMask = {
|
||||
filter1: {
|
||||
id: 'filter1',
|
||||
filterState: { value: '25' },
|
||||
extraFormData: {},
|
||||
},
|
||||
} as any;
|
||||
const chart = {
|
||||
queriesResponse: [
|
||||
{ rejected_filters: [] },
|
||||
{ rejected_filters: [{ column: 'age' }] },
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectNativeIndicatorsForChart(
|
||||
nativeFilters,
|
||||
dataMask,
|
||||
chartId,
|
||||
chart,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
column: 'age',
|
||||
name: 'Age',
|
||||
path: ['filter1'],
|
||||
status: IndicatorStatus.Incompatible,
|
||||
value: '25',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -141,9 +141,20 @@ const selectIndicatorsForChartFromFilter = (
|
||||
}));
|
||||
};
|
||||
|
||||
const getQueryFilterMetadata = (
|
||||
chart: any,
|
||||
metadataKey: 'applied_filters' | 'rejected_filters',
|
||||
) =>
|
||||
ensureIsArray(chart?.queriesResponse).flatMap(
|
||||
queryResponse =>
|
||||
(metadataKey === 'applied_filters'
|
||||
? queryResponse?.applied_filters
|
||||
: queryResponse?.rejected_filters) || [],
|
||||
);
|
||||
|
||||
const getAppliedColumns = (chart: any): Set<string> =>
|
||||
new Set(
|
||||
(chart?.queriesResponse?.[0]?.applied_filters || []).map(
|
||||
getQueryFilterMetadata(chart, 'applied_filters').map(
|
||||
(filter: any) => filter.column,
|
||||
),
|
||||
);
|
||||
@@ -161,8 +172,7 @@ export const getAppliedColumnsWithFallback = (
|
||||
chartId?: number,
|
||||
): Set<string> => {
|
||||
// First try to get from query response (preferred source of truth)
|
||||
const queryAppliedFilters =
|
||||
chart?.queriesResponse?.[0]?.applied_filters || [];
|
||||
const queryAppliedFilters = getQueryFilterMetadata(chart, 'applied_filters');
|
||||
if (queryAppliedFilters.length > 0) {
|
||||
return new Set(queryAppliedFilters.map((filter: any) => filter.column));
|
||||
}
|
||||
@@ -191,7 +201,7 @@ export const getAppliedColumnsWithFallback = (
|
||||
|
||||
const getRejectedColumns = (chart: any): Set<string> =>
|
||||
new Set(
|
||||
(chart?.queriesResponse?.[0]?.rejected_filters || []).map((filter: any) =>
|
||||
getQueryFilterMetadata(chart, 'rejected_filters').map((filter: any) =>
|
||||
getColumnLabel(filter.column),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -42,6 +42,7 @@ import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
|
||||
import { LocalStorageKeys, setItem } from 'src/utils/localStorageHelpers';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { sanitizeDocumentTitle } from 'src/utils/sanitizeDocumentTitle';
|
||||
import { setDatasetsStatus } from 'src/dashboard/actions/dashboardState';
|
||||
import { DASHBOARD_HEADER_ID } from 'src/dashboard/util/constants';
|
||||
import {
|
||||
@@ -337,7 +338,7 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
// Update document title when dashboard title changes
|
||||
useEffect(() => {
|
||||
if (pageTitle) {
|
||||
document.title = pageTitle;
|
||||
document.title = sanitizeDocumentTitle(pageTitle);
|
||||
}
|
||||
}, [pageTitle]);
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS,
|
||||
} from 'src/logger/LogUtils';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { sanitizeDocumentTitle } from 'src/utils/sanitizeDocumentTitle';
|
||||
import cx from 'classnames';
|
||||
import * as chartActions from 'src/components/Chart/chartAction';
|
||||
import { fetchDatasourceMetadata } from 'src/dashboard/actions/datasources';
|
||||
@@ -397,7 +398,7 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
|
||||
// Update document title when slice name changes
|
||||
useEffect(() => {
|
||||
if (props.sliceName) {
|
||||
document.title = props.sliceName;
|
||||
document.title = sanitizeDocumentTitle(props.sliceName);
|
||||
}
|
||||
}, [props.sliceName]);
|
||||
|
||||
|
||||
+113
-63
@@ -339,7 +339,34 @@ export const useExploreAdditionalActionsMenu = (
|
||||
}
|
||||
}, [addDangerToast, latestQueryFormData, permalinkChartState]);
|
||||
|
||||
const exportCSV = useCallback(() => {
|
||||
const handleExportError = useCallback(
|
||||
(error: unknown) => {
|
||||
const exportError = error as Error & {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
response?: { status?: number };
|
||||
};
|
||||
const status = exportError.status || exportError.response?.status;
|
||||
if (status === 413) {
|
||||
addDangerToast(
|
||||
t(
|
||||
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const errorMessage =
|
||||
exportError.message ||
|
||||
exportError.statusText ||
|
||||
t(
|
||||
'Failed to export chart data. Please try again or contact your administrator.',
|
||||
);
|
||||
addDangerToast(errorMessage);
|
||||
}
|
||||
},
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
const exportCSV = useCallback(async () => {
|
||||
if (!canDownloadCSV) return null;
|
||||
|
||||
// Determine row count for streaming threshold check
|
||||
@@ -378,26 +405,31 @@ export const useExploreAdditionalActionsMenu = (
|
||||
filename = `${safeChartName}${timestamp}.csv`;
|
||||
}
|
||||
|
||||
return exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'full',
|
||||
resultFormat: 'csv',
|
||||
onStartStreamingExport: shouldUseStreaming
|
||||
? exportParams => {
|
||||
if (exportParams.url) {
|
||||
setIsStreamingModalVisible(true);
|
||||
startExport({
|
||||
...exportParams,
|
||||
url: exportParams.url,
|
||||
filename,
|
||||
expectedRows: actualRowCount,
|
||||
exportType: exportParams.exportType as 'csv' | 'xlsx',
|
||||
});
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'full',
|
||||
resultFormat: 'csv',
|
||||
onStartStreamingExport: shouldUseStreaming
|
||||
? exportParams => {
|
||||
if (exportParams.url) {
|
||||
setIsStreamingModalVisible(true);
|
||||
startExport({
|
||||
...exportParams,
|
||||
url: exportParams.url,
|
||||
filename,
|
||||
expectedRows: actualRowCount,
|
||||
exportType: exportParams.exportType as 'csv' | 'xlsx',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
: null,
|
||||
});
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
return null;
|
||||
}, [
|
||||
canDownloadCSV,
|
||||
latestQueryFormData,
|
||||
@@ -406,46 +438,59 @@ export const useExploreAdditionalActionsMenu = (
|
||||
streamingThreshold,
|
||||
slice,
|
||||
startExport,
|
||||
handleExportError,
|
||||
]);
|
||||
|
||||
const exportCSVPivoted = useCallback(
|
||||
() =>
|
||||
canDownloadCSV
|
||||
? exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'post_processed',
|
||||
resultFormat: 'csv',
|
||||
})
|
||||
: null,
|
||||
[canDownloadCSV, latestQueryFormData, ownState],
|
||||
);
|
||||
const exportCSVPivoted = useCallback(async () => {
|
||||
if (!canDownloadCSV) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'post_processed',
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const exportJson = useCallback(
|
||||
() =>
|
||||
canDownloadCSV
|
||||
? exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'json',
|
||||
})
|
||||
: null,
|
||||
[canDownloadCSV, latestQueryFormData, ownState],
|
||||
);
|
||||
const exportJson = useCallback(async () => {
|
||||
if (!canDownloadCSV) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'json',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const exportExcel = useCallback(
|
||||
() =>
|
||||
canDownloadCSV
|
||||
? exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'xlsx',
|
||||
})
|
||||
: null,
|
||||
[canDownloadCSV, latestQueryFormData, ownState],
|
||||
);
|
||||
const exportExcel = useCallback(async () => {
|
||||
if (!canDownloadCSV) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'xlsx',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const copyLink = useCallback(async () => {
|
||||
try {
|
||||
@@ -805,7 +850,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
label: dataExportLabel(t('Export to .CSV')),
|
||||
icon: <Icons.FileOutlined />,
|
||||
disabled: !canDownloadCSV,
|
||||
onClick: () => {
|
||||
onClick: async () => {
|
||||
// Use 'results' to export the *current view* (as opposed to 'full').
|
||||
// Pass ownState so client/UI state (e.g., filters) can be respected when supported.
|
||||
if (
|
||||
@@ -820,12 +865,16 @@ export const useExploreAdditionalActionsMenu = (
|
||||
slice?.slice_name || 'current_view',
|
||||
);
|
||||
} else {
|
||||
exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
}
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
@@ -1058,6 +1107,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
exportCSVPivoted,
|
||||
exportExcel,
|
||||
exportJson,
|
||||
handleExportError,
|
||||
latestQueryFormData,
|
||||
onOpenInEditor,
|
||||
onOpenPropertiesModal,
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 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 { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useExploreAdditionalActionsMenu } from './index';
|
||||
import * as exploreUtils from 'src/explore/exploreUtils';
|
||||
|
||||
jest.mock('src/explore/exploreUtils', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('src/explore/exploreUtils'),
|
||||
exportChart: jest.fn(),
|
||||
getChartKey: jest.fn(() => 'test_chart_key'),
|
||||
}));
|
||||
|
||||
const mockExportChart = exploreUtils.exportChart as jest.Mock;
|
||||
|
||||
const mockAddDangerToast = jest.fn();
|
||||
jest.mock('src/components/MessageToasts/withToasts', () => ({
|
||||
__esModule: true,
|
||||
default: (component: ComponentType) => component,
|
||||
useToasts: () => ({
|
||||
addDangerToast: mockAddDangerToast,
|
||||
addSuccessToast: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('src/logger/actions', () => ({
|
||||
logEvent: jest.fn(() => ({ type: 'LOG_EVENT' })),
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
getChartMetadataRegistry: jest.fn(() => ({
|
||||
get: jest.fn(() => ({ behaviors: ['EXPORT_CURRENT_VIEW'] })),
|
||||
})),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
latestQueryFormData: {
|
||||
datasource: '1__table',
|
||||
viz_type: 'pivot_table_v2',
|
||||
},
|
||||
canDownloadCSV: true,
|
||||
slice: { slice_id: 1, slice_name: 'Test Chart' },
|
||||
ownState: {},
|
||||
dashboards: [],
|
||||
onOpenInEditor: jest.fn(),
|
||||
onOpenPropertiesModal: jest.fn(),
|
||||
showReportModal: jest.fn(),
|
||||
setCurrentReportDeleting: jest.fn(),
|
||||
};
|
||||
|
||||
type TestComponentProps = typeof defaultProps;
|
||||
type HookParams = Parameters<typeof useExploreAdditionalActionsMenu>;
|
||||
|
||||
const TestComponent = (props: TestComponentProps) => {
|
||||
const [menu] = useExploreAdditionalActionsMenu(
|
||||
props.latestQueryFormData as HookParams[0],
|
||||
props.canDownloadCSV,
|
||||
props.slice as HookParams[2],
|
||||
props.onOpenInEditor,
|
||||
props.onOpenPropertiesModal,
|
||||
props.ownState as HookParams[5],
|
||||
props.dashboards as HookParams[6],
|
||||
props.showReportModal,
|
||||
props.setCurrentReportDeleting,
|
||||
);
|
||||
|
||||
return <div>{menu}</div>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockExportChart.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test('shows 413 error toast when exportCSV fails with 413', async () => {
|
||||
mockExportChart.mockRejectedValue({ status: 413 });
|
||||
|
||||
render(<TestComponent {...defaultProps} />, { useRedux: true });
|
||||
|
||||
userEvent.hover(await screen.findByText('Data Export Options'));
|
||||
userEvent.hover(await screen.findByText('Export All Data'));
|
||||
userEvent.click(await screen.findByText('Export to original .CSV'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/The chart data is too large to download/),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('shows 413 error toast when exportCSVPivoted fails with 413', async () => {
|
||||
mockExportChart.mockRejectedValue({ status: 413 });
|
||||
|
||||
render(<TestComponent {...defaultProps} />, { useRedux: true });
|
||||
|
||||
userEvent.hover(await screen.findByText('Data Export Options'));
|
||||
userEvent.hover(await screen.findByText('Export All Data'));
|
||||
userEvent.click(await screen.findByText('Export to pivoted .CSV'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/The chart data is too large to download/),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('shows 413 error toast when Export Current View CSV server path fails with 413', async () => {
|
||||
mockExportChart.mockRejectedValue({ status: 413 });
|
||||
|
||||
render(
|
||||
<TestComponent
|
||||
{...defaultProps}
|
||||
latestQueryFormData={{
|
||||
datasource: '1__table',
|
||||
viz_type: 'table',
|
||||
}}
|
||||
ownState={{}}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.hover(await screen.findByText('Data Export Options'));
|
||||
userEvent.hover(await screen.findByText('Export Current View'));
|
||||
userEvent.click(await screen.findByText('Export to .CSV'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/The chart data is too large to download/),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,11 @@
|
||||
*/
|
||||
import { exportChart } from '.';
|
||||
|
||||
jest.mock('src/utils/export', () => ({
|
||||
...jest.requireActual('src/utils/export'),
|
||||
downloadBlob: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock pathUtils to control app root prefix
|
||||
jest.mock('src/utils/pathUtils', () => ({
|
||||
ensureAppRoot: jest.fn((path: string) => path),
|
||||
@@ -27,6 +32,7 @@ jest.mock('src/utils/pathUtils', () => ({
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
SupersetClient: {
|
||||
postBlob: jest.fn(),
|
||||
postForm: jest.fn(),
|
||||
get: jest.fn().mockResolvedValue({ json: {} }),
|
||||
post: jest.fn().mockResolvedValue({ json: {} }),
|
||||
@@ -41,6 +47,14 @@ jest.mock('@superset-ui/core', () => ({
|
||||
|
||||
const { ensureAppRoot } = jest.requireMock('src/utils/pathUtils');
|
||||
const { getChartMetadataRegistry } = jest.requireMock('@superset-ui/core');
|
||||
const { downloadBlob } = jest.requireMock('src/utils/export');
|
||||
|
||||
const mockBlob = new Blob(['test data'], { type: 'text/csv' });
|
||||
|
||||
const createMockExportResponse = (headers: Headers = new Headers()) => ({
|
||||
headers,
|
||||
blob: jest.fn().mockResolvedValue(mockBlob),
|
||||
});
|
||||
|
||||
// Minimal formData that won't trigger legacy API (useLegacyApi = false)
|
||||
const baseFormData = {
|
||||
@@ -113,22 +127,24 @@ test('exportChart v1 API passes nested prefix for deeply nested deployments', as
|
||||
expect(callArgs.exportType).toBe('xlsx');
|
||||
});
|
||||
|
||||
// Regression test for the double-prefix bug: SupersetClient.postForm adds appRoot
|
||||
// Regression test for the double-prefix bug: SupersetClient.postBlob adds appRoot
|
||||
// internally via getUrl(), so the URL passed must NOT already be prefixed.
|
||||
test('exportChart v1 API calls postForm with unprefixed URL when app root is configured', async () => {
|
||||
test('exportChart v1 API calls postBlob with unprefixed URL when app root is configured', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const appRoot = '/analytics';
|
||||
ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`);
|
||||
SupersetClient.postBlob.mockResolvedValue(createMockExportResponse());
|
||||
|
||||
await exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
|
||||
expect(SupersetClient.postForm).toHaveBeenCalledTimes(1);
|
||||
const [url] = SupersetClient.postForm.mock.calls[0];
|
||||
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
|
||||
const [url] = SupersetClient.postBlob.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/chart/data');
|
||||
expect(url).not.toContain(appRoot);
|
||||
expect(downloadBlob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('exportChart passes csv exportType for CSV exports', async () => {
|
||||
@@ -240,9 +256,10 @@ test('exportChart legacy API builds relative URL for xlsx export', async () => {
|
||||
expect(callArgs.url).toBe('/superset/explore_json/?xlsx=true');
|
||||
});
|
||||
|
||||
test('exportChart legacy API calls postForm with relative URL', async () => {
|
||||
test('exportChart legacy API calls postBlob with relative URL', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
ensureAppRoot.mockImplementation((path: string) => path);
|
||||
SupersetClient.postBlob.mockResolvedValue(createMockExportResponse());
|
||||
|
||||
getChartMetadataRegistry.mockReturnValue({
|
||||
get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' }),
|
||||
@@ -259,10 +276,11 @@ test('exportChart legacy API calls postForm with relative URL', async () => {
|
||||
resultType: 'full',
|
||||
});
|
||||
|
||||
expect(SupersetClient.postForm).toHaveBeenCalledTimes(1);
|
||||
const [url] = SupersetClient.postForm.mock.calls[0];
|
||||
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
|
||||
const [url] = SupersetClient.postBlob.mock.calls[0];
|
||||
expect(url).toBe('/superset/explore_json/?csv=true');
|
||||
expect(url).not.toMatch(/^https?:\/\//);
|
||||
expect(downloadBlob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('exportChart legacy API includes force param when force=true', async () => {
|
||||
@@ -289,3 +307,187 @@ test('exportChart legacy API includes force param when force=true', async () =>
|
||||
const callArgs = onStartStreamingExport.mock.calls[0][0];
|
||||
expect(callArgs.url).toBe('/superset/explore_json/?force=true&csv=true');
|
||||
});
|
||||
|
||||
test('exportChart successfully exports chart as CSV', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const mockResponse = createMockExportResponse();
|
||||
SupersetClient.postBlob.mockResolvedValue(mockResponse);
|
||||
|
||||
await exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'csv',
|
||||
resultType: 'full',
|
||||
});
|
||||
|
||||
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
|
||||
expect(mockResponse.blob).toHaveBeenCalled();
|
||||
expect(downloadBlob).toHaveBeenCalledWith(
|
||||
mockBlob,
|
||||
expect.stringContaining('.csv'),
|
||||
);
|
||||
});
|
||||
|
||||
test('exportChart successfully exports chart as Excel', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const mockResponse = createMockExportResponse();
|
||||
SupersetClient.postBlob.mockResolvedValue(mockResponse);
|
||||
|
||||
await exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'xlsx',
|
||||
resultType: 'results',
|
||||
});
|
||||
|
||||
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
|
||||
expect(mockResponse.blob).toHaveBeenCalled();
|
||||
expect(downloadBlob).toHaveBeenCalledWith(
|
||||
mockBlob,
|
||||
expect.stringContaining('.xlsx'),
|
||||
);
|
||||
});
|
||||
|
||||
test('exportChart throws error with status 413 when payload is too large', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const mockErrorResponse = new Response('Payload Too Large', {
|
||||
status: 413,
|
||||
statusText: 'Payload Too Large',
|
||||
});
|
||||
SupersetClient.postBlob.mockRejectedValue(mockErrorResponse);
|
||||
|
||||
await expect(
|
||||
exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'csv',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
status: 413,
|
||||
message: expect.stringContaining('413'),
|
||||
});
|
||||
|
||||
expect(downloadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('exportChart throws error with status 500 for server errors', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const mockErrorResponse = new Response('Internal Server Error', {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
});
|
||||
SupersetClient.postBlob.mockRejectedValue(mockErrorResponse);
|
||||
|
||||
await expect(
|
||||
exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'json',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
status: 500,
|
||||
message: expect.stringContaining('500'),
|
||||
});
|
||||
|
||||
expect(downloadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('exportChart enhances errors without status property', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const genericError = new Error('Network error');
|
||||
SupersetClient.postBlob.mockRejectedValue(genericError);
|
||||
|
||||
await expect(
|
||||
exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'csv',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
status: 500,
|
||||
message: expect.stringContaining('Network error'),
|
||||
});
|
||||
|
||||
expect(downloadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('exportChart uses streaming export when onStartStreamingExport is provided', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const mockStreamingHandler = jest.fn();
|
||||
|
||||
await exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'csv',
|
||||
onStartStreamingExport: mockStreamingHandler as unknown as null,
|
||||
});
|
||||
|
||||
expect(mockStreamingHandler).toHaveBeenCalledTimes(1);
|
||||
expect(mockStreamingHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: '/api/v1/chart/data',
|
||||
exportType: 'csv',
|
||||
}),
|
||||
);
|
||||
expect(SupersetClient.postBlob).not.toHaveBeenCalled();
|
||||
expect(downloadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('exportChart generates correct filename with timestamp', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const mockResponse = createMockExportResponse();
|
||||
SupersetClient.postBlob.mockResolvedValue(mockResponse);
|
||||
|
||||
const mockDate = new Date('2025-01-14T12:34:56.789Z');
|
||||
jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
|
||||
|
||||
await exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
|
||||
expect(downloadBlob).toHaveBeenCalledWith(
|
||||
mockBlob,
|
||||
expect.stringMatching(
|
||||
/^chart_export_\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.csv$/,
|
||||
),
|
||||
);
|
||||
|
||||
jest.spyOn(global, 'Date').mockRestore();
|
||||
});
|
||||
|
||||
test('exportChart uses filename from Content-Disposition header', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const mockResponse = createMockExportResponse(
|
||||
new Headers({
|
||||
'Content-Disposition': 'attachment; filename="export.zip"',
|
||||
}),
|
||||
);
|
||||
SupersetClient.postBlob.mockResolvedValue(mockResponse);
|
||||
|
||||
await exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
|
||||
expect(downloadBlob).toHaveBeenCalledWith(mockBlob, 'export.zip');
|
||||
});
|
||||
|
||||
test('exportChart uses zip extension when Content-Type is application/zip', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const mockDate = new Date('2025-01-14T12:34:56.789Z');
|
||||
jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
|
||||
|
||||
const mockResponse = createMockExportResponse(
|
||||
new Headers({
|
||||
'Content-Type': 'application/zip',
|
||||
}),
|
||||
);
|
||||
SupersetClient.postBlob.mockResolvedValue(mockResponse);
|
||||
|
||||
await exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
|
||||
expect(downloadBlob).toHaveBeenCalledWith(
|
||||
mockBlob,
|
||||
'chart_export_2025-01-14T12-34-56.zip',
|
||||
);
|
||||
|
||||
jest.spyOn(global, 'Date').mockRestore();
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ 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 { downloadBlob, getFilenameFromResponse } from 'src/utils/export';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import {
|
||||
DISABLE_INPUT_OPERATORS,
|
||||
@@ -398,11 +399,54 @@ export const exportChart = async ({
|
||||
exportSource: 'chart',
|
||||
});
|
||||
} else {
|
||||
// SupersetClient.postForm calls getUrl({ endpoint }) internally, which prepends
|
||||
// Use AJAX blob download instead of form submission to enable error handling.
|
||||
// SupersetClient.postBlob calls getUrl({ endpoint }) internally, which prepends
|
||||
// appRoot — so the URL must NOT be pre-prefixed here.
|
||||
SupersetClient.postForm(url as string, {
|
||||
form_data: safeStringify(payload),
|
||||
});
|
||||
try {
|
||||
const response = await SupersetClient.postBlob(url as string, {
|
||||
form_data: safeStringify(payload),
|
||||
});
|
||||
|
||||
const extension = resultFormat === 'xlsx' ? 'xlsx' : resultFormat;
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, '-')
|
||||
.slice(0, -5);
|
||||
const fallbackFilename = `chart_export_${timestamp}.${extension}`;
|
||||
const filename = getFilenameFromResponse(response, fallbackFilename);
|
||||
|
||||
const blob = await response.blob();
|
||||
downloadBlob(blob, filename);
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
const responseError = new Error(
|
||||
`HTTP ${error.status} ${error.statusText}`,
|
||||
) as Error & {
|
||||
status: number;
|
||||
statusText: string;
|
||||
response: Response;
|
||||
};
|
||||
responseError.status = error.status;
|
||||
responseError.statusText = error.statusText;
|
||||
responseError.response = error;
|
||||
throw responseError;
|
||||
}
|
||||
|
||||
const exportError = error as Error & {
|
||||
status?: number;
|
||||
originalError?: unknown;
|
||||
};
|
||||
if (!exportError.status) {
|
||||
const enhancedError = new Error(
|
||||
exportError.message || 'Export failed',
|
||||
) as Error & { status: number; originalError: unknown };
|
||||
enhancedError.status = 500;
|
||||
enhancedError.originalError = error;
|
||||
throw enhancedError;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ test('handles create dashboard button click', async () => {
|
||||
|
||||
const createButton = screen.getByRole('button', { name: /dashboard$/i });
|
||||
await userEvent.click(createButton);
|
||||
expect(assignMock).toHaveBeenCalledWith('/dashboard/new');
|
||||
expect(assignMock).toHaveBeenCalledWith('/dashboard/new/');
|
||||
locationSpy.mockRestore();
|
||||
});
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ function DashboardTable({
|
||||
name: t('Dashboard'),
|
||||
buttonStyle: 'secondary',
|
||||
onClick: () => {
|
||||
navigateTo('/dashboard/new', { assign: true });
|
||||
navigateTo('/dashboard/new/', { assign: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -57,7 +57,7 @@ const LABELS = {
|
||||
const REDIRECTS = {
|
||||
create: {
|
||||
[WelcomeTable.Charts]: '/chart/add',
|
||||
[WelcomeTable.Dashboards]: '/dashboard/new',
|
||||
[WelcomeTable.Dashboards]: '/dashboard/new/',
|
||||
// navigateTo() applies the application root internally; keep this
|
||||
// relative so the prefix isn't added twice.
|
||||
[WelcomeTable.SavedQueries]: '/sqllab?new=true',
|
||||
|
||||
@@ -89,7 +89,7 @@ const dropdownItems = [
|
||||
},
|
||||
{
|
||||
label: 'Dashboard',
|
||||
url: '/dashboard/new',
|
||||
url: '/dashboard/new/',
|
||||
icon: 'fa-fw fa-dashboard',
|
||||
perm: 'can_write',
|
||||
view: 'Dashboard',
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
|
||||
import { isFeatureEnabled, FeatureFlag, CACHE_KEY } from '@superset-ui/core';
|
||||
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
||||
import RightMenu from './RightMenu';
|
||||
import { GlobalMenuDataOptions, RightMenuProps } from './types';
|
||||
@@ -105,7 +105,7 @@ const dropdownItems = [
|
||||
},
|
||||
{
|
||||
label: 'Dashboard',
|
||||
url: '/dashboard/new',
|
||||
url: '/dashboard/new/',
|
||||
icon: 'fa-fw fa-dashboard',
|
||||
perm: 'can_write',
|
||||
view: 'Dashboard',
|
||||
@@ -401,17 +401,35 @@ test('Logs out and clears local storage item redux', async () => {
|
||||
expect(localStorage.getItem('redux')).not.toBeNull();
|
||||
expect(sessionStorage.getItem('login_attempted')).not.toBeNull();
|
||||
|
||||
await userEvent.hover(await screen.findByText(/Settings/i));
|
||||
// Mock the Cache API so we can assert the namespaced store is purged.
|
||||
const cacheGlobal = global as unknown as { caches?: CacheStorage };
|
||||
const priorCaches = cacheGlobal.caches;
|
||||
const deleteMock = jest.fn().mockResolvedValue(true);
|
||||
cacheGlobal.caches = { delete: deleteMock } as unknown as CacheStorage;
|
||||
|
||||
// Simulate user clicking the logout button
|
||||
const logoutButton = await screen.findByText('Logout');
|
||||
await userEvent.click(logoutButton);
|
||||
try {
|
||||
await userEvent.hover(await screen.findByText(/Settings/i));
|
||||
|
||||
// Wait for local and session storage to be cleared
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('redux')).toBeNull();
|
||||
expect(sessionStorage.getItem('login_attempted')).toBeNull();
|
||||
});
|
||||
// Simulate user clicking the logout button
|
||||
const logoutButton = await screen.findByText('Logout');
|
||||
await userEvent.click(logoutButton);
|
||||
|
||||
// Wait for local and session storage to be cleared
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('redux')).toBeNull();
|
||||
expect(sessionStorage.getItem('login_attempted')).toBeNull();
|
||||
});
|
||||
// The namespaced Cache API store is purged on logout.
|
||||
expect(deleteMock).toHaveBeenCalledWith(CACHE_KEY);
|
||||
} finally {
|
||||
// Restore the global so an early assertion failure cannot leak the mock
|
||||
// into other tests.
|
||||
if (priorCaches === undefined) {
|
||||
delete cacheGlobal.caches;
|
||||
} else {
|
||||
cacheGlobal.caches = priorCaches;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('shows logout button when not embedded', async () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
getExtensionsRegistry,
|
||||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
CACHE_KEY,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
styled,
|
||||
@@ -232,7 +233,7 @@ const RightMenu = ({
|
||||
},
|
||||
{
|
||||
label: t('Dashboard'),
|
||||
url: '/dashboard/new',
|
||||
url: '/dashboard/new/',
|
||||
icon: (
|
||||
<Icons.DashboardOutlined data-test={`menu-item-${t('Dashboard')}`} />
|
||||
),
|
||||
@@ -353,6 +354,14 @@ const RightMenu = ({
|
||||
try {
|
||||
window.localStorage.removeItem('redux');
|
||||
window.sessionStorage.removeItem('login_attempted');
|
||||
// Purge the namespaced Cache API store so cached GET responses are not
|
||||
// retained on the device after the session ends. Best-effort: the
|
||||
// returned promise is not awaited since logout navigates away.
|
||||
if (typeof caches !== 'undefined') {
|
||||
caches.delete(CACHE_KEY).catch(() => {
|
||||
/* best-effort: ignore cache deletion failures */
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear storage on logout:', error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
RoleNameField,
|
||||
PermissionsField,
|
||||
UsersField,
|
||||
GroupsField,
|
||||
} from './RoleFormItems';
|
||||
|
||||
jest.mock('./utils', () => ({
|
||||
fetchPermissionOptions: jest.fn(),
|
||||
fetchGroupOptions: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../groups/utils', () => ({
|
||||
fetchUserOptions: jest.fn(),
|
||||
}));
|
||||
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
test('RoleNameField renders label and input', () => {
|
||||
render(<RoleNameField />);
|
||||
expect(screen.getByText('Role Name')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('role-name-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('PermissionsField renders label and select', () => {
|
||||
render(<PermissionsField addDangerToast={addDangerToast} />);
|
||||
expect(screen.getByText('Permissions')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('permissions-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('PermissionsField renders loading state', () => {
|
||||
render(<PermissionsField addDangerToast={addDangerToast} loading />);
|
||||
expect(screen.getByText('Permissions')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('permissions-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('UsersField renders label and select', () => {
|
||||
render(<UsersField addDangerToast={addDangerToast} loading={false} />);
|
||||
expect(screen.getByText('Users')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('roles-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('GroupsField renders label and select', () => {
|
||||
render(<GroupsField addDangerToast={addDangerToast} />);
|
||||
expect(screen.getByText('Groups')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('groups-select')).toBeInTheDocument();
|
||||
});
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback } from 'react';
|
||||
import { FormItem, Input, AsyncSelect } from '@superset-ui/core/components';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { fetchUserOptions } from '../groups/utils';
|
||||
@@ -44,51 +45,69 @@ export const RoleNameField = () => (
|
||||
export const PermissionsField = ({
|
||||
addDangerToast,
|
||||
loading = false,
|
||||
}: AsyncOptionsFieldProps) => (
|
||||
<FormItem name="rolePermissions" label={t('Permissions')}>
|
||||
<AsyncSelect
|
||||
mode="multiple"
|
||||
name="rolePermissions"
|
||||
placeholder={t('Select permissions')}
|
||||
options={(filterValue, page, pageSize) =>
|
||||
fetchPermissionOptions(filterValue, page, pageSize, addDangerToast)
|
||||
}
|
||||
loading={loading}
|
||||
getPopupContainer={trigger => trigger.closest('.ant-modal-content')}
|
||||
data-test="permissions-select"
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
}: AsyncOptionsFieldProps) => {
|
||||
const options = useCallback(
|
||||
(filterValue: string, page: number, pageSize: number) =>
|
||||
fetchPermissionOptions(filterValue, page, pageSize, addDangerToast),
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
export const UsersField = ({ addDangerToast, loading }: UsersFieldProps) => (
|
||||
<FormItem name="roleUsers" label={t('Users')}>
|
||||
<AsyncSelect
|
||||
name="roleUsers"
|
||||
mode="multiple"
|
||||
placeholder={t('Select users')}
|
||||
options={(filterValue, page, pageSize) =>
|
||||
fetchUserOptions(filterValue, page, pageSize, addDangerToast)
|
||||
}
|
||||
loading={loading}
|
||||
data-test="roles-select"
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
return (
|
||||
<FormItem name="rolePermissions" label={t('Permissions')}>
|
||||
<AsyncSelect
|
||||
mode="multiple"
|
||||
name="rolePermissions"
|
||||
placeholder={t('Select permissions')}
|
||||
options={options}
|
||||
loading={loading}
|
||||
getPopupContainer={trigger => trigger.closest('.ant-modal-content')}
|
||||
data-test="permissions-select"
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
};
|
||||
|
||||
export const UsersField = ({ addDangerToast, loading }: UsersFieldProps) => {
|
||||
const options = useCallback(
|
||||
(filterValue: string, page: number, pageSize: number) =>
|
||||
fetchUserOptions(filterValue, page, pageSize, addDangerToast),
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormItem name="roleUsers" label={t('Users')}>
|
||||
<AsyncSelect
|
||||
name="roleUsers"
|
||||
mode="multiple"
|
||||
placeholder={t('Select users')}
|
||||
options={options}
|
||||
loading={loading}
|
||||
data-test="roles-select"
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupsField = ({
|
||||
addDangerToast,
|
||||
loading = false,
|
||||
}: AsyncOptionsFieldProps) => (
|
||||
<FormItem name="roleGroups" label={t('Groups')}>
|
||||
<AsyncSelect
|
||||
mode="multiple"
|
||||
name="roleGroups"
|
||||
placeholder={t('Select groups')}
|
||||
options={(filterValue, page, pageSize) =>
|
||||
fetchGroupOptions(filterValue, page, pageSize, addDangerToast)
|
||||
}
|
||||
loading={loading}
|
||||
data-test="groups-select"
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
}: AsyncOptionsFieldProps) => {
|
||||
const options = useCallback(
|
||||
(filterValue: string, page: number, pageSize: number) =>
|
||||
fetchGroupOptions(filterValue, page, pageSize, addDangerToast),
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormItem name="roleGroups" label={t('Groups')}>
|
||||
<AsyncSelect
|
||||
mode="multiple"
|
||||
name="roleGroups"
|
||||
placeholder={t('Select groups')}
|
||||
options={options}
|
||||
loading={loading}
|
||||
data-test="groups-select"
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -59,11 +59,15 @@ test('fetchPermissionOptions fetches all results on page 0 with large page_size'
|
||||
expect(queries).toContainEqual({
|
||||
page: 0,
|
||||
page_size: 1000,
|
||||
order_column: 'id',
|
||||
order_direction: 'asc',
|
||||
filters: [{ col: 'view_menu.name', opr: 'ct', value: 'dataset' }],
|
||||
});
|
||||
expect(queries).toContainEqual({
|
||||
page: 0,
|
||||
page_size: 1000,
|
||||
order_column: 'id',
|
||||
order_direction: 'asc',
|
||||
filters: [{ col: 'permission.name', opr: 'ct', value: 'dataset' }],
|
||||
});
|
||||
|
||||
@@ -125,6 +129,8 @@ test('fetchPermissionOptions makes single request when search term is empty', as
|
||||
expect(rison.decode(queryString)).toEqual({
|
||||
page: 0,
|
||||
page_size: 100,
|
||||
order_column: 'id',
|
||||
order_direction: 'asc',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,6 +242,8 @@ test('fetchGroupOptions sends filters array with search term', async () => {
|
||||
expect(rison.decode(queryString)).toEqual({
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
order_column: 'name',
|
||||
order_direction: 'asc',
|
||||
filters: [{ col: 'name', opr: 'ct', value: 'eng' }],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
@@ -261,6 +269,8 @@ test('fetchGroupOptions omits filters when search term is empty', async () => {
|
||||
expect(rison.decode(queryString)).toEqual({
|
||||
page: 0,
|
||||
page_size: 100,
|
||||
order_column: 'name',
|
||||
order_direction: 'asc',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -94,11 +94,14 @@ const fetchPermissionPageRaw = async (queryParams: Record<string, unknown>) => {
|
||||
const fetchAllPermissionPages = async (
|
||||
filters: Record<string, unknown>[],
|
||||
): Promise<SelectOption[]> => {
|
||||
const page0 = await fetchPermissionPageRaw({
|
||||
page: 0,
|
||||
const baseQuery = {
|
||||
page_size: PAGE_SIZE,
|
||||
order_column: 'id',
|
||||
order_direction: 'asc',
|
||||
filters,
|
||||
});
|
||||
};
|
||||
|
||||
const page0 = await fetchPermissionPageRaw({ ...baseQuery, page: 0 });
|
||||
if (page0.data.length === 0 || page0.data.length >= page0.totalCount) {
|
||||
return page0.data;
|
||||
}
|
||||
@@ -113,11 +116,7 @@ const fetchAllPermissionPages = async (
|
||||
const batchEnd = Math.min(batch + CONCURRENCY_LIMIT, totalPages);
|
||||
const batchResults = await Promise.all(
|
||||
Array.from({ length: batchEnd - batch }, (_, i) =>
|
||||
fetchPermissionPageRaw({
|
||||
page: batch + i,
|
||||
page_size: PAGE_SIZE,
|
||||
filters,
|
||||
}),
|
||||
fetchPermissionPageRaw({ ...baseQuery, page: batch + i }),
|
||||
),
|
||||
);
|
||||
for (const r of batchResults) {
|
||||
@@ -138,7 +137,12 @@ export const fetchPermissionOptions = async (
|
||||
) => {
|
||||
if (!filterValue) {
|
||||
try {
|
||||
return await fetchPermissionPageRaw({ page, page_size: pageSize });
|
||||
return await fetchPermissionPageRaw({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
order_column: 'id',
|
||||
order_direction: 'asc',
|
||||
});
|
||||
} catch {
|
||||
addDangerToast(t('There was an error while fetching permissions'));
|
||||
return { data: [], totalCount: 0 };
|
||||
@@ -193,6 +197,8 @@ export const fetchGroupOptions = async (
|
||||
const query = rison.encode({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
order_column: 'name',
|
||||
order_direction: 'asc',
|
||||
...(filterValue
|
||||
? { filters: [{ col: 'name', opr: 'ct', value: filterValue }] }
|
||||
: {}),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 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
|
||||
* 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
|
||||
@@ -36,80 +36,77 @@ const mockedProps = {
|
||||
resourceName: 'dashboard',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('BulkTagModal', () => {
|
||||
afterEach(() => {
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
jest.clearAllMocks();
|
||||
afterEach(() => {
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render', () => {
|
||||
const { container } = render(<BulkTagModal {...mockedProps} />);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the correct title and message', () => {
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
expect(
|
||||
screen.getByText(/you are adding tags to 2 dashboards/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Bulk tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders tags input field', async () => {
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
|
||||
expect(tagsInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onHide when the Cancel button is clicked', () => {
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
fireEvent.click(cancelButton);
|
||||
expect(mockedProps.onHide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('submits the selected tags and shows success toast', async () => {
|
||||
fetchMock.post('glob:*/api/v1/tag/bulk_create', {
|
||||
result: {
|
||||
objects_tagged: [1, 2],
|
||||
objects_skipped: [],
|
||||
},
|
||||
});
|
||||
|
||||
test('should render', () => {
|
||||
const { container } = render(<BulkTagModal {...mockedProps} />);
|
||||
expect(container).toBeInTheDocument();
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
|
||||
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
|
||||
fireEvent.change(tagsInput, { target: { value: 'Test Tag' } });
|
||||
fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedProps.addSuccessToast).toHaveBeenCalledWith(
|
||||
'Tagged 2 dashboards',
|
||||
);
|
||||
});
|
||||
|
||||
test('renders the correct title and message', () => {
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
expect(
|
||||
screen.getByText(/you are adding tags to 2 dashboards/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Bulk tag')).toBeInTheDocument();
|
||||
});
|
||||
expect(mockedProps.refreshData).toHaveBeenCalled();
|
||||
expect(mockedProps.onHide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('renders tags input field', async () => {
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
|
||||
expect(tagsInput).toBeInTheDocument();
|
||||
});
|
||||
test('handles API errors gracefully', async () => {
|
||||
fetchMock.post('glob:*/api/v1/tag/bulk_create', 500);
|
||||
|
||||
test('calls onHide when the Cancel button is clicked', () => {
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
fireEvent.click(cancelButton);
|
||||
expect(mockedProps.onHide).toHaveBeenCalled();
|
||||
});
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
|
||||
test('submits the selected tags and shows success toast', async () => {
|
||||
fetchMock.post('glob:*/api/v1/tag/bulk_create', {
|
||||
result: {
|
||||
objects_tagged: [1, 2],
|
||||
objects_skipped: [],
|
||||
},
|
||||
});
|
||||
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
|
||||
fireEvent.change(tagsInput, { target: { value: 'Test Tag' } });
|
||||
fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
|
||||
fireEvent.change(tagsInput, { target: { value: 'Test Tag' } });
|
||||
fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedProps.addSuccessToast).toHaveBeenCalledWith(
|
||||
'Tagged 2 dashboards',
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedProps.refreshData).toHaveBeenCalled();
|
||||
expect(mockedProps.onHide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('handles API errors gracefully', async () => {
|
||||
fetchMock.post('glob:*/api/v1/tag/bulk_create', 500);
|
||||
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
|
||||
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
|
||||
fireEvent.change(tagsInput, { target: { value: 'Test Tag' } });
|
||||
fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedProps.addDangerToast).toHaveBeenCalledWith(
|
||||
'Failed to tag items',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockedProps.addDangerToast).toHaveBeenCalledWith(
|
||||
'Failed to tag items',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1658,3 +1658,42 @@ test('renders standard Select dropdown when operatorType is Exact', () => {
|
||||
|
||||
expect(screen.getAllByRole('combobox').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('renders dashboard select dropdown popup under document body', async () => {
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
render(<SelectFilterPlugin {...buildSelectFilterProps()} />, {
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
nativeFilters: {
|
||||
filters: { 'test-filter': { name: 'Test Filter' } },
|
||||
},
|
||||
dataMask: {
|
||||
'test-filter': {
|
||||
extraFormData: {
|
||||
filters: [{ col: 'gender', op: 'IN', val: ['boy'] }],
|
||||
},
|
||||
filterState: {
|
||||
value: ['boy'],
|
||||
label: 'boy',
|
||||
excludeFilterValues: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [filterSelect] = screen.getAllByRole('combobox');
|
||||
userEvent.click(filterSelect);
|
||||
|
||||
let dropdown: Element | undefined;
|
||||
await waitFor(() => {
|
||||
dropdown = Array.from(
|
||||
document.querySelectorAll('.ant-select-dropdown'),
|
||||
).find(
|
||||
element => !element.classList.contains('ant-select-dropdown-hidden'),
|
||||
);
|
||||
expect(dropdown).toBeDefined();
|
||||
});
|
||||
|
||||
expect(dropdown?.parentElement).toBe(document.body);
|
||||
});
|
||||
|
||||
@@ -539,6 +539,19 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
[debouncedLikeChange],
|
||||
);
|
||||
|
||||
const getSelectPopupContainer = useCallback(
|
||||
(trigger: HTMLElement) => {
|
||||
if (showOverflow) {
|
||||
return (parentRef?.current as HTMLElement) || document.body;
|
||||
}
|
||||
if (appSection === AppSection.FilterConfigModal) {
|
||||
return (trigger?.parentNode as HTMLElement) || document.body;
|
||||
}
|
||||
return document.body;
|
||||
},
|
||||
[appSection, parentRef, showOverflow],
|
||||
);
|
||||
|
||||
const likeInputPlaceholder = useMemo(() => {
|
||||
switch (operatorType) {
|
||||
case SelectFilterOperatorType.Contains:
|
||||
@@ -571,6 +584,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
{ value: 'false', label: t('is') },
|
||||
]}
|
||||
onChange={handleExclusionToggle}
|
||||
getPopupContainer={getSelectPopupContainer}
|
||||
/>
|
||||
)}
|
||||
{isLikeOperator ? (
|
||||
@@ -595,12 +609,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
allowSelectAll={!searchAllOptions}
|
||||
value={multiSelect ? filterState.value || [] : filterState.value}
|
||||
disabled={isDisabled}
|
||||
getPopupContainer={
|
||||
showOverflow
|
||||
? () => (parentRef?.current as HTMLElement) || document.body
|
||||
: (trigger: HTMLElement) =>
|
||||
(trigger?.parentNode as HTMLElement) || document.body
|
||||
}
|
||||
getPopupContainer={getSelectPopupContainer}
|
||||
showSearch={showSearch}
|
||||
mode={multiSelect ? 'multiple' : 'single'}
|
||||
placeholder={placeholderText}
|
||||
|
||||
@@ -38,7 +38,6 @@ const TestComponent = (props: ThemeSubMenuProps) => {
|
||||
return <Menu items={[menuItem]} />;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('useThemeMenuItems', () => {
|
||||
const defaultProps = {
|
||||
allowOSPreference: true,
|
||||
|
||||
@@ -762,7 +762,7 @@ function DashboardList(props: DashboardListProps) {
|
||||
name: t('Dashboard'),
|
||||
buttonStyle: 'primary',
|
||||
onClick: () => {
|
||||
navigateTo('/dashboard/new', { assign: true });
|
||||
navigateTo('/dashboard/new/', { assign: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* "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
|
||||
* 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
|
||||
@@ -27,133 +27,127 @@ import {
|
||||
ColumnDefinition,
|
||||
} from 'src/utils/common';
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('utils/common', () => {
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('optionFromValue', () => {
|
||||
test('converts values as expected', () => {
|
||||
expect(optionFromValue(false)).toEqual({
|
||||
value: false,
|
||||
label: FALSE_STRING,
|
||||
});
|
||||
expect(optionFromValue(true)).toEqual({
|
||||
value: true,
|
||||
label: TRUE_STRING,
|
||||
});
|
||||
expect(optionFromValue(null)).toEqual({
|
||||
value: NULL_STRING,
|
||||
label: NULL_STRING,
|
||||
});
|
||||
expect(optionFromValue('')).toEqual({
|
||||
value: '',
|
||||
label: '<empty string>',
|
||||
});
|
||||
expect(optionFromValue('foo')).toEqual({ value: 'foo', label: 'foo' });
|
||||
expect(optionFromValue(5)).toEqual({ value: 5, label: '5' });
|
||||
});
|
||||
test('converts values as expected', () => {
|
||||
expect(optionFromValue(false)).toEqual({
|
||||
value: false,
|
||||
label: FALSE_STRING,
|
||||
});
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('prepareCopyToClipboardTabularData', () => {
|
||||
test('converts empty array', () => {
|
||||
const data: TabularDataRow[] = [];
|
||||
const columns: string[] = [];
|
||||
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual('');
|
||||
});
|
||||
test('converts non empty array', () => {
|
||||
const data: TabularDataRow[] = [
|
||||
{ column1: 'lorem', column2: 'ipsum' },
|
||||
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
|
||||
];
|
||||
const columns: string[] = ['column1', 'column2', 'column3'];
|
||||
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(
|
||||
'column1\tcolumn2\tcolumn3\nlorem\tipsum\t\ndolor\tsit\tamet\n',
|
||||
);
|
||||
});
|
||||
test('includes 0 values and handle column objects', () => {
|
||||
const data: TabularDataRow[] = [
|
||||
{ column1: 0, column2: 0 },
|
||||
{ column1: 1, column2: -1, 0: 0 },
|
||||
];
|
||||
const columns: ColumnDefinition[] = [
|
||||
{ name: 'column1' },
|
||||
{ name: 'column2' },
|
||||
{ name: '0' },
|
||||
];
|
||||
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(
|
||||
'column1\tcolumn2\t0\n0\t0\t\n1\t-1\t0\n',
|
||||
);
|
||||
});
|
||||
expect(optionFromValue(true)).toEqual({
|
||||
value: true,
|
||||
label: TRUE_STRING,
|
||||
});
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('applyFormattingToTabularData', () => {
|
||||
test('does not mutate empty array', () => {
|
||||
const data: TabularDataRow[] = [];
|
||||
expect(applyFormattingToTabularData(data, [])).toEqual(data);
|
||||
});
|
||||
test('does not mutate array without temporal column', () => {
|
||||
const data: TabularDataRow[] = [
|
||||
{ column1: 'lorem', column2: 'ipsum' },
|
||||
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
|
||||
];
|
||||
expect(applyFormattingToTabularData(data, [])).toEqual(data);
|
||||
});
|
||||
test('changes formatting of columns selected for formatting', () => {
|
||||
const originalData: TabularDataRow[] = [
|
||||
{
|
||||
__timestamp: null,
|
||||
column1: 'lorem',
|
||||
column2: 1590014060000,
|
||||
column3: 1507680000000,
|
||||
},
|
||||
{
|
||||
__timestamp: 0,
|
||||
column1: 'ipsum',
|
||||
column2: 1590075817000,
|
||||
column3: 1513641600000,
|
||||
},
|
||||
{
|
||||
__timestamp: 1594285437771,
|
||||
column1: 'dolor',
|
||||
column2: 1591062977000,
|
||||
column3: 1516924800000,
|
||||
},
|
||||
{
|
||||
__timestamp: 1594285441675,
|
||||
column1: 'sit',
|
||||
column2: 1591397351000,
|
||||
column3: 1518566400000,
|
||||
},
|
||||
];
|
||||
const timeFormattedColumns: string[] = ['__timestamp', 'column3'];
|
||||
const expectedData: TabularDataRow[] = [
|
||||
{
|
||||
__timestamp: null,
|
||||
column1: 'lorem',
|
||||
column2: 1590014060000,
|
||||
column3: '2017-10-11 00:00:00',
|
||||
},
|
||||
{
|
||||
__timestamp: '1970-01-01 00:00:00',
|
||||
column1: 'ipsum',
|
||||
column2: 1590075817000,
|
||||
column3: '2017-12-19 00:00:00',
|
||||
},
|
||||
{
|
||||
__timestamp: '2020-07-09 09:03:57',
|
||||
column1: 'dolor',
|
||||
column2: 1591062977000,
|
||||
column3: '2018-01-26 00:00:00',
|
||||
},
|
||||
{
|
||||
__timestamp: '2020-07-09 09:04:01',
|
||||
column1: 'sit',
|
||||
column2: 1591397351000,
|
||||
column3: '2018-02-14 00:00:00',
|
||||
},
|
||||
];
|
||||
expect(
|
||||
applyFormattingToTabularData(originalData, timeFormattedColumns),
|
||||
).toEqual(expectedData);
|
||||
});
|
||||
expect(optionFromValue(null)).toEqual({
|
||||
value: NULL_STRING,
|
||||
label: NULL_STRING,
|
||||
});
|
||||
expect(optionFromValue('')).toEqual({
|
||||
value: '',
|
||||
label: '<empty string>',
|
||||
});
|
||||
expect(optionFromValue('foo')).toEqual({ value: 'foo', label: 'foo' });
|
||||
expect(optionFromValue(5)).toEqual({ value: 5, label: '5' });
|
||||
});
|
||||
|
||||
test('converts empty array', () => {
|
||||
const data: TabularDataRow[] = [];
|
||||
const columns: string[] = [];
|
||||
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual('');
|
||||
});
|
||||
|
||||
test('converts non empty array', () => {
|
||||
const data: TabularDataRow[] = [
|
||||
{ column1: 'lorem', column2: 'ipsum' },
|
||||
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
|
||||
];
|
||||
const columns: string[] = ['column1', 'column2', 'column3'];
|
||||
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(
|
||||
'column1\tcolumn2\tcolumn3\nlorem\tipsum\t\ndolor\tsit\tamet\n',
|
||||
);
|
||||
});
|
||||
|
||||
test('includes 0 values and handle column objects', () => {
|
||||
const data: TabularDataRow[] = [
|
||||
{ column1: 0, column2: 0 },
|
||||
{ column1: 1, column2: -1, 0: 0 },
|
||||
];
|
||||
const columns: ColumnDefinition[] = [
|
||||
{ name: 'column1' },
|
||||
{ name: 'column2' },
|
||||
{ name: '0' },
|
||||
];
|
||||
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(
|
||||
'column1\tcolumn2\t0\n0\t0\t\n1\t-1\t0\n',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not mutate empty array', () => {
|
||||
const data: TabularDataRow[] = [];
|
||||
expect(applyFormattingToTabularData(data, [])).toEqual(data);
|
||||
});
|
||||
|
||||
test('does not mutate array without temporal column', () => {
|
||||
const data: TabularDataRow[] = [
|
||||
{ column1: 'lorem', column2: 'ipsum' },
|
||||
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
|
||||
];
|
||||
expect(applyFormattingToTabularData(data, [])).toEqual(data);
|
||||
});
|
||||
|
||||
test('changes formatting of columns selected for formatting', () => {
|
||||
const originalData: TabularDataRow[] = [
|
||||
{
|
||||
__timestamp: null,
|
||||
column1: 'lorem',
|
||||
column2: 1590014060000,
|
||||
column3: 1507680000000,
|
||||
},
|
||||
{
|
||||
__timestamp: 0,
|
||||
column1: 'ipsum',
|
||||
column2: 1590075817000,
|
||||
column3: 1513641600000,
|
||||
},
|
||||
{
|
||||
__timestamp: 1594285437771,
|
||||
column1: 'dolor',
|
||||
column2: 1591062977000,
|
||||
column3: 1516924800000,
|
||||
},
|
||||
{
|
||||
__timestamp: 1594285441675,
|
||||
column1: 'sit',
|
||||
column2: 1591397351000,
|
||||
column3: 1518566400000,
|
||||
},
|
||||
];
|
||||
const timeFormattedColumns: string[] = ['__timestamp', 'column3'];
|
||||
const expectedData: TabularDataRow[] = [
|
||||
{
|
||||
__timestamp: null,
|
||||
column1: 'lorem',
|
||||
column2: 1590014060000,
|
||||
column3: '2017-10-11 00:00:00',
|
||||
},
|
||||
{
|
||||
__timestamp: '1970-01-01 00:00:00',
|
||||
column1: 'ipsum',
|
||||
column2: 1590075817000,
|
||||
column3: '2017-12-19 00:00:00',
|
||||
},
|
||||
{
|
||||
__timestamp: '2020-07-09 09:03:57',
|
||||
column1: 'dolor',
|
||||
column2: 1591062977000,
|
||||
column3: '2018-01-26 00:00:00',
|
||||
},
|
||||
{
|
||||
__timestamp: '2020-07-09 09:04:01',
|
||||
column1: 'sit',
|
||||
column2: 1591397351000,
|
||||
column3: '2018-02-14 00:00:00',
|
||||
},
|
||||
];
|
||||
expect(
|
||||
applyFormattingToTabularData(originalData, timeFormattedColumns),
|
||||
).toEqual(expectedData);
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { parse as parseContentDisposition } from 'content-disposition';
|
||||
import handleResourceExport from './export';
|
||||
import handleResourceExport, { getFilenameFromResponse } from './export';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
@@ -454,3 +454,59 @@ test.each(doublePrefixTestCases)(
|
||||
(ensureAppRoot as jest.Mock).mockImplementation((path: string) => path);
|
||||
},
|
||||
);
|
||||
|
||||
test('getFilenameFromResponse returns filename from Content-Disposition', () => {
|
||||
(parseContentDisposition as jest.Mock).mockReturnValueOnce({
|
||||
parameters: { filename: 'server_export.csv' },
|
||||
});
|
||||
const response = {
|
||||
headers: new Headers({
|
||||
'Content-Disposition': 'attachment; filename="server_export.csv"',
|
||||
}),
|
||||
} as Response;
|
||||
|
||||
expect(getFilenameFromResponse(response, 'fallback.csv')).toBe(
|
||||
'server_export.csv',
|
||||
);
|
||||
});
|
||||
|
||||
test('getFilenameFromResponse uses zip extension when Content-Type is zip', () => {
|
||||
const response = {
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/zip',
|
||||
}),
|
||||
} as Response;
|
||||
|
||||
expect(getFilenameFromResponse(response, 'chart_export_2025.csv')).toBe(
|
||||
'chart_export_2025.zip',
|
||||
);
|
||||
});
|
||||
|
||||
test('getFilenameFromResponse returns fallback when no headers match', () => {
|
||||
const response = {
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
|
||||
expect(getFilenameFromResponse(response, 'chart_export_2025.csv')).toBe(
|
||||
'chart_export_2025.csv',
|
||||
);
|
||||
});
|
||||
|
||||
test('getFilenameFromResponse falls back when Content-Disposition parsing fails', () => {
|
||||
(parseContentDisposition as jest.Mock).mockImplementationOnce(() => {
|
||||
throw new Error('Parse error');
|
||||
});
|
||||
const response = {
|
||||
headers: new Headers({
|
||||
'Content-Disposition': 'invalid',
|
||||
}),
|
||||
} as Response;
|
||||
|
||||
expect(getFilenameFromResponse(response, 'fallback.csv')).toBe(
|
||||
'fallback.csv',
|
||||
);
|
||||
expect(logging.warn).toHaveBeenCalledWith(
|
||||
'Failed to parse Content-Disposition header:',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,35 @@ const MAX_BLOB_SIZE = 100 * 1024 * 1024;
|
||||
* @param blob - The blob to download
|
||||
* @param fileName - The filename to use for the download
|
||||
*/
|
||||
function downloadBlob(blob: Blob, fileName: string): void {
|
||||
/**
|
||||
* Derives a download filename from response headers, falling back when absent.
|
||||
*/
|
||||
export function getFilenameFromResponse(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): string {
|
||||
const disposition = response.headers.get('Content-Disposition');
|
||||
if (disposition) {
|
||||
try {
|
||||
const parsed = parseContentDisposition(disposition);
|
||||
if (parsed?.parameters?.filename) {
|
||||
return parsed.parameters.filename;
|
||||
}
|
||||
} catch (error) {
|
||||
logging.warn('Failed to parse Content-Disposition header:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type') ?? '';
|
||||
if (contentType.includes('zip')) {
|
||||
const base = fallback.replace(/\.[^.]+$/, '');
|
||||
return `${base}.zip`;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, fileName: string): void {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
try {
|
||||
const a = document.createElement('a');
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { sanitizeDocumentTitle } from './sanitizeDocumentTitle';
|
||||
|
||||
test('removes all C0 control characters including tab/LF/CR', () => {
|
||||
expect(sanitizeDocumentTitle('a\x08b')).toBe('ab');
|
||||
expect(sanitizeDocumentTitle('x\x09y')).toBe('xy');
|
||||
expect(sanitizeDocumentTitle('x\ny')).toBe('xy');
|
||||
expect(sanitizeDocumentTitle('x\ry')).toBe('xy');
|
||||
});
|
||||
|
||||
test('removes DEL and C1 controls', () => {
|
||||
expect(sanitizeDocumentTitle('a\x7Fb')).toBe('ab');
|
||||
expect(sanitizeDocumentTitle('a\x9Fb')).toBe('ab');
|
||||
});
|
||||
|
||||
test('leaves normal text unchanged', () => {
|
||||
expect(sanitizeDocumentTitle('Dashboard 你好')).toBe('Dashboard 你好');
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Strip all C0/C1 control characters (U+0000–U+001F, U+007F–U+009F).
|
||||
* Headless browsers (Playwright/Chromium) can hang or crash when document.title
|
||||
* contains characters such as U+0008 (backspace).
|
||||
*/
|
||||
export function sanitizeDocumentTitle(title: string): string {
|
||||
return title.replace(/[\x00-\x1F\x7F-\x9F]/g, '');
|
||||
}
|
||||
@@ -17,8 +17,6 @@
|
||||
# isort:skip_file
|
||||
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import uuid as uuid_module
|
||||
from typing import Any, Optional, Callable
|
||||
from collections.abc import Iterator
|
||||
@@ -50,13 +48,6 @@ DEFAULT_CHART_HEIGHT = 50
|
||||
DEFAULT_CHART_WIDTH = 4
|
||||
|
||||
|
||||
def suffix(length: int = 8) -> str:
|
||||
return "".join(
|
||||
random.SystemRandom().choice(string.ascii_uppercase + string.digits)
|
||||
for _ in range(length)
|
||||
)
|
||||
|
||||
|
||||
def get_default_position(title: str) -> dict[str, Any]:
|
||||
return {
|
||||
"DASHBOARD_VERSION_KEY": "v2",
|
||||
@@ -72,12 +63,12 @@ def get_default_position(title: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def append_charts(position: dict[str, Any], charts: set[Slice]) -> dict[str, Any]:
|
||||
chart_hashes = [f"CHART-{suffix()}" for _ in charts]
|
||||
chart_hashes = [f"CHART-{str(chart.uuid)}" for chart in charts]
|
||||
|
||||
# if we have ROOT_ID/GRID_ID, append orphan charts to a new row inside the grid
|
||||
row_hash = None
|
||||
if "ROOT_ID" in position and "GRID_ID" in position["ROOT_ID"]["children"]:
|
||||
row_hash = f"ROW-N-{suffix()}"
|
||||
row_hash = f"ROW-N-{len(position['GRID_ID']['children'])}"
|
||||
position["GRID_ID"]["children"].append(row_hash)
|
||||
position[row_hash] = {
|
||||
"children": chart_hashes,
|
||||
|
||||
@@ -77,6 +77,7 @@ from superset.utils import json
|
||||
from superset.utils.core import HeaderDataType, override_user, recipients_string_to_list
|
||||
from superset.utils.csv import get_chart_csv_data, get_chart_dataframe
|
||||
from superset.utils.decorators import logs_context, transaction
|
||||
from superset.utils.file import sanitize_title
|
||||
from superset.utils.pdf import build_pdf_from_screenshots
|
||||
from superset.utils.screenshots import ChartScreenshot, DashboardScreenshot
|
||||
from superset.utils.slack import get_channels_with_search, SlackChannelTypes
|
||||
@@ -701,7 +702,7 @@ class BaseReportState:
|
||||
error_text = "Unexpected missing csv file"
|
||||
if error_text:
|
||||
return NotificationContent(
|
||||
name=self._report_schedule.name,
|
||||
name=sanitize_title(self._report_schedule.name),
|
||||
text=error_text,
|
||||
header_data=header_data,
|
||||
url=url,
|
||||
@@ -714,15 +715,15 @@ class BaseReportState:
|
||||
embedded_data = self._get_embedded_data()
|
||||
|
||||
if self._report_schedule.email_subject:
|
||||
name = self._report_schedule.email_subject
|
||||
name = sanitize_title(self._report_schedule.email_subject)
|
||||
else:
|
||||
if self._report_schedule.chart:
|
||||
name = (
|
||||
name = sanitize_title(
|
||||
f"{self._report_schedule.name}: "
|
||||
f"{self._report_schedule.chart.slice_name}"
|
||||
)
|
||||
else:
|
||||
name = (
|
||||
name = sanitize_title(
|
||||
f"{self._report_schedule.name}: "
|
||||
f"{self._report_schedule.dashboard.dashboard_title}"
|
||||
)
|
||||
@@ -821,7 +822,7 @@ class BaseReportState:
|
||||
self._execution_id,
|
||||
)
|
||||
notification_content = NotificationContent(
|
||||
name=name, text=message, header_data=header_data, url=url
|
||||
name=sanitize_title(name), text=message, header_data=header_data, url=url
|
||||
)
|
||||
|
||||
# filter recipients to recipients who are also owners
|
||||
|
||||
@@ -50,14 +50,25 @@ class UpdateRLSRuleCommand(BaseCommand):
|
||||
self._model = RLSDAO.find_by_id(int(self._model_id))
|
||||
if not self._model:
|
||||
raise RLSRuleNotFoundError()
|
||||
roles = populate_roles(self._roles)
|
||||
tables = (
|
||||
db.session.query(SqlaTable)
|
||||
.filter(SqlaTable.id.in_(self._tables)) # type: ignore[attr-defined]
|
||||
.all()
|
||||
)
|
||||
if len(tables) != len(self._tables):
|
||||
raise DatasourceNotFoundValidationError()
|
||||
raise_for_datasource_access(tables)
|
||||
self._properties["roles"] = roles
|
||||
self._properties["tables"] = tables
|
||||
# Only resolve and overwrite the relationships that are actually present
|
||||
# in the request body. A partial update (e.g. changing only the name)
|
||||
# must leave the rule's existing tables/roles bindings untouched rather
|
||||
# than replacing them with empty lists.
|
||||
if "roles" in self._properties:
|
||||
self._properties["roles"] = populate_roles(self._roles)
|
||||
if "tables" in self._properties:
|
||||
tables = (
|
||||
db.session.query(SqlaTable)
|
||||
.filter(SqlaTable.id.in_(self._tables)) # type: ignore[attr-defined]
|
||||
.all()
|
||||
)
|
||||
if len(tables) != len(self._tables):
|
||||
raise DatasourceNotFoundValidationError()
|
||||
raise_for_datasource_access(tables)
|
||||
self._properties["tables"] = tables
|
||||
else:
|
||||
# A partial update that omits ``tables`` still mutates the rule, so
|
||||
# enforce datasource access against the rule's existing tables to
|
||||
# avoid letting a caller edit a rule bound to datasources they
|
||||
# cannot access.
|
||||
raise_for_datasource_access(self._model.tables)
|
||||
|
||||
+49
-7
@@ -408,20 +408,30 @@ AUTH_PASSWORD_COMMON_BLOCKLIST: list[str] = []
|
||||
APP_NAME = "Superset"
|
||||
|
||||
# Specify the App icon
|
||||
# NOTE: This variable is used to populate THEME_DEFAULT. If you override this in
|
||||
# superset_config.py, you must also override THEME_DEFAULT to see the change,
|
||||
# or set THEME_DEFAULT["token"]["brandLogoUrl"] directly.
|
||||
APP_ICON = "/static/assets/images/superset-logo-horiz.png"
|
||||
|
||||
# Specify where clicking the logo would take the user'
|
||||
# Specify where clicking the logo would take the user
|
||||
# Default value of None will take you to '/superset/welcome'
|
||||
# You can also specify a relative URL e.g. '/superset/welcome' or '/dashboards/list'
|
||||
# or you can specify a full URL e.g. 'https://foo.bar'
|
||||
# NOTE: Overriding this in superset_config.py automatically updates the logo link
|
||||
# (THEME_DEFAULT["token"]["brandLogoHref"]); see sync_theme_logo_href below.
|
||||
LOGO_TARGET_PATH = None
|
||||
|
||||
# Specify tooltip that should appear when hovering over the App Icon/Logo
|
||||
# NOTE: This variable is deprecated and not used in the new theme system.
|
||||
LOGO_TOOLTIP = ""
|
||||
|
||||
# Specify any text that should appear to the right of the logo
|
||||
# NOTE: This variable is deprecated and not used in the new theme system.
|
||||
LOGO_RIGHT_TEXT: Callable[[], str] | str = ""
|
||||
|
||||
# APP_ICON_WIDTH is deprecated.
|
||||
# Use THEME_DEFAULT["token"]["brandLogoHeight"] instead (default: "24px").
|
||||
|
||||
# Enables SWAGGER UI for superset openapi spec
|
||||
# ex: http://localhost:8080/swagger/v1
|
||||
FAB_API_SWAGGER_UI = True
|
||||
@@ -1000,7 +1010,12 @@ EXTRA_CATEGORICAL_COLOR_SCHEMES: list[dict[str, Any]] = []
|
||||
|
||||
# Default theme configuration - foundation for all themes
|
||||
# This acts as the base theme for all users
|
||||
THEME_DEFAULT: Theme = {
|
||||
#
|
||||
# _THEME_DEFAULT_BASE is a private copy of the built-in defaults.
|
||||
# It is NOT overridden by ``from superset_config import *`` (underscore prefix)
|
||||
# and is used to deep-merge partial user overrides so that unspecified token
|
||||
# fields gracefully fall back to the built-in values.
|
||||
_THEME_DEFAULT_BASE: Theme = {
|
||||
"token": {
|
||||
# Brand
|
||||
# Application name for window titles
|
||||
@@ -1008,9 +1023,10 @@ THEME_DEFAULT: Theme = {
|
||||
"brandLogoAlt": "Apache Superset",
|
||||
"brandLogoUrl": APP_ICON,
|
||||
"brandLogoMargin": "18px 0",
|
||||
"brandLogoHref": "/",
|
||||
"brandLogoHref": LOGO_TARGET_PATH or "/",
|
||||
"brandLogoHeight": "24px",
|
||||
# Spinner
|
||||
# Spinner - Set this to use a custom GIF/image loader
|
||||
# "brandSpinnerUrl": "/static/assets/images/loading.gif",
|
||||
"brandSpinnerUrl": None,
|
||||
"brandSpinnerSvg": None,
|
||||
# Default colors
|
||||
@@ -1039,19 +1055,39 @@ THEME_DEFAULT: Theme = {
|
||||
"algorithm": "default",
|
||||
}
|
||||
|
||||
THEME_DEFAULT: Theme = _THEME_DEFAULT_BASE
|
||||
|
||||
# Dark theme configuration - foundation for dark mode
|
||||
# Inherits all tokens from THEME_DEFAULT and adds dark algorithm
|
||||
# Set to None to disable dark mode
|
||||
THEME_DARK: Optional[Theme] = {
|
||||
**THEME_DEFAULT,
|
||||
_THEME_DARK_BASE: Theme = {
|
||||
**_THEME_DEFAULT_BASE,
|
||||
"token": {
|
||||
**THEME_DEFAULT["token"],
|
||||
**_THEME_DEFAULT_BASE["token"],
|
||||
# Darker selection color for dark mode
|
||||
"colorEditorSelection": "#5c4d1a",
|
||||
},
|
||||
"algorithm": "dark",
|
||||
}
|
||||
|
||||
THEME_DARK: Optional[Theme] = _THEME_DARK_BASE
|
||||
|
||||
|
||||
def sync_theme_logo_href(
|
||||
theme: Optional[Theme], logo_target_path: Optional[str]
|
||||
) -> None:
|
||||
"""
|
||||
Apply ``LOGO_TARGET_PATH`` to a theme's ``brandLogoHref`` token.
|
||||
|
||||
``THEME_DEFAULT`` / ``THEME_DARK`` are built above, before ``superset_config.py``
|
||||
and environment overrides are applied at the bottom of this module. This is
|
||||
re-run after those overrides so that setting only ``LOGO_TARGET_PATH`` updates
|
||||
the logo link without also having to override the whole theme object.
|
||||
"""
|
||||
if theme and logo_target_path and isinstance(theme.get("token"), dict):
|
||||
theme["token"]["brandLogoHref"] = logo_target_path
|
||||
|
||||
|
||||
# Theme behavior and user preference settings
|
||||
# To force a single theme on all users, set THEME_DARK = None
|
||||
# When both THEME_DEFAULT and THEME_DARK are defined:
|
||||
@@ -2831,3 +2867,9 @@ for env_var in ENV_VAR_KEYS:
|
||||
if env_var in os.environ:
|
||||
config_var = env_var.replace("SUPERSET__", "")
|
||||
globals()[config_var] = os.environ[env_var]
|
||||
|
||||
# THEME_DEFAULT / THEME_DARK are defined before the overrides above are applied,
|
||||
# so re-sync the logo link from the final LOGO_TARGET_PATH value here. This lets
|
||||
# users set just LOGO_TARGET_PATH without also overriding the whole theme.
|
||||
sync_theme_logo_href(THEME_DEFAULT, LOGO_TARGET_PATH)
|
||||
sync_theme_logo_href(THEME_DARK, LOGO_TARGET_PATH)
|
||||
|
||||
@@ -512,7 +512,10 @@ class BigQueryEngineSpec(BaseEngineSpec): # pylint: disable=too-many-public-met
|
||||
database, catalog=table.catalog, schema=table.schema
|
||||
) as engine:
|
||||
client = cls._get_client(engine, database)
|
||||
bq_table = client.get_table(f"{table.schema}.{table.table}")
|
||||
table_ref = f"{table.schema}.{table.table}"
|
||||
if table.catalog:
|
||||
table_ref = f"{table.catalog}.{table_ref}"
|
||||
bq_table = client.get_table(table_ref)
|
||||
|
||||
if bq_table.time_partitioning:
|
||||
return bq_table.time_partitioning.field
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Pydantic schemas for explore-related MCP tool outputs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from superset.mcp_service.common.error_schemas import ChartGenerationError
|
||||
|
||||
|
||||
class GenerateExploreLinkResponse(BaseModel):
|
||||
"""
|
||||
Output schema for the generate_explore_link tool.
|
||||
|
||||
On success, ``url`` is a fully-qualified Superset Explore URL that the
|
||||
user can open immediately, and ``form_data_key`` can be used to
|
||||
reconstruct or share the same configuration. On failure, ``url`` is
|
||||
empty and ``error`` is a ``ChartGenerationError``; its ``error_type``
|
||||
distinguishes ``dataset_not_found``, ``permission_denied``,
|
||||
``validation_error``, and ``generation_failed`` so callers can branch
|
||||
on failure mode without parsing free-text messages.
|
||||
"""
|
||||
|
||||
url: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Explore URL — open in a browser to view the interactive chart. "
|
||||
"Empty string on failure."
|
||||
),
|
||||
)
|
||||
form_data: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Raw Superset form_data dict that was encoded into the URL.",
|
||||
)
|
||||
permalink_key: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Durable permalink key for the generated Explore URL, when one "
|
||||
"was created. Prefer this over ``form_data_key`` for sharing; it "
|
||||
"survives cache eviction. Null on failure or when only an "
|
||||
"ephemeral form_data key is available."
|
||||
),
|
||||
)
|
||||
form_data_key: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Short, ephemeral cache key that represents this form_data "
|
||||
"configuration. Populated only when no ``permalink_key`` is "
|
||||
"available. Can be passed to the Explore UI as ?form_data_key=<key>."
|
||||
),
|
||||
)
|
||||
chart_type_label: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Human-readable label for the resulting chart type "
|
||||
"(e.g. 'table chart', 'interactive table chart'). "
|
||||
"Null on failure or when the viz_type has no specific label."
|
||||
),
|
||||
)
|
||||
error: ChartGenerationError | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Structured ChartGenerationError when generation fails, else "
|
||||
"null. Branch on error.error_type to handle specific failure "
|
||||
"modes (dataset_not_found, permission_denied, validation_error, "
|
||||
"generation_failed)."
|
||||
),
|
||||
)
|
||||
success: bool = Field(
|
||||
True,
|
||||
description="True when a valid URL was produced, False on any error.",
|
||||
)
|
||||
@@ -23,16 +23,14 @@ chart configuration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.auth import has_dataset_access
|
||||
from superset.mcp_service.chart.chart_helpers import (
|
||||
extract_form_data_key_from_url,
|
||||
)
|
||||
from superset.mcp_service.chart.chart_helpers import extract_form_data_key_from_url
|
||||
from superset.mcp_service.chart.chart_utils import (
|
||||
generate_explore_link as generate_url,
|
||||
get_table_chart_type_label,
|
||||
@@ -42,8 +40,12 @@ from superset.mcp_service.chart.compile import validate_and_compile
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
GenerateExploreLinkRequest,
|
||||
)
|
||||
from superset.mcp_service.chart.validation.dataset_validator import DatasetValidator
|
||||
from superset.mcp_service.common.error_schemas import ChartGenerationError
|
||||
from superset.mcp_service.explore.schemas import GenerateExploreLinkResponse
|
||||
from superset.mcp_service.utils.url_utils import (
|
||||
extract_permalink_key_from_url,
|
||||
get_superset_base_url,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -60,7 +62,7 @@ logger = logging.getLogger(__name__)
|
||||
)
|
||||
async def generate_explore_link(
|
||||
request: GenerateExploreLinkRequest, ctx: Context
|
||||
) -> Dict[str, Any]:
|
||||
) -> GenerateExploreLinkResponse:
|
||||
"""Generate explore URL for interactive visualization.
|
||||
|
||||
PREFERRED TOOL for most visualization requests.
|
||||
@@ -118,8 +120,6 @@ async def generate_explore_link(
|
||||
try:
|
||||
await ctx.report_progress(1, 4, "Validating dataset exists")
|
||||
with event_logger.log_context(action="mcp.generate_explore_link.dataset_check"):
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
|
||||
dataset = None
|
||||
if isinstance(request.dataset_id, int) or (
|
||||
isinstance(request.dataset_id, str) and request.dataset_id.isdigit()
|
||||
@@ -137,17 +137,28 @@ async def generate_explore_link(
|
||||
await ctx.warning(
|
||||
"Dataset not found: dataset_id=%s" % (request.dataset_id,)
|
||||
)
|
||||
return {
|
||||
"url": "",
|
||||
"form_data": {},
|
||||
"permalink_key": None,
|
||||
"form_data_key": None,
|
||||
"chart_type_label": None,
|
||||
"error": (
|
||||
f"Dataset not found: {request.dataset_id}. "
|
||||
"Use list_datasets to find valid dataset IDs."
|
||||
return GenerateExploreLinkResponse(
|
||||
url="",
|
||||
form_data={},
|
||||
permalink_key=None,
|
||||
form_data_key=None,
|
||||
chart_type_label=None,
|
||||
error=ChartGenerationError(
|
||||
error_type="dataset_not_found",
|
||||
error_code="MCP_EXPLORE_DATASET_NOT_FOUND",
|
||||
message=f"Dataset not found: {request.dataset_id}.",
|
||||
details=(
|
||||
f"No dataset found with identifier "
|
||||
f"'{request.dataset_id}'. Use list_datasets to "
|
||||
"find valid dataset IDs."
|
||||
),
|
||||
suggestions=[
|
||||
"Verify the dataset ID or UUID is correct",
|
||||
"Use the list_datasets tool to find available datasets",
|
||||
],
|
||||
),
|
||||
}
|
||||
success=False,
|
||||
)
|
||||
|
||||
if not has_dataset_access(dataset):
|
||||
logger.warning(
|
||||
@@ -157,24 +168,39 @@ async def generate_explore_link(
|
||||
await ctx.warning(
|
||||
"Dataset access denied: dataset_id=%s" % (request.dataset_id,)
|
||||
)
|
||||
return {
|
||||
"url": "",
|
||||
"form_data": {},
|
||||
"permalink_key": None,
|
||||
"form_data_key": None,
|
||||
"chart_type_label": None,
|
||||
"error": (
|
||||
f"Dataset not found: {request.dataset_id}. "
|
||||
"Use list_datasets to find valid dataset IDs."
|
||||
# User-facing message stays generic to avoid leaking dataset
|
||||
# existence; error_type lets programmatic callers distinguish.
|
||||
return GenerateExploreLinkResponse(
|
||||
url="",
|
||||
form_data={},
|
||||
permalink_key=None,
|
||||
form_data_key=None,
|
||||
chart_type_label=None,
|
||||
error=ChartGenerationError(
|
||||
error_type="permission_denied",
|
||||
# Same code as the not-found path: the user-visible
|
||||
# message is intentionally indistinguishable so
|
||||
# access policy isn't disclosed; ``error_type`` is
|
||||
# the programmatic distinguisher.
|
||||
error_code="MCP_EXPLORE_DATASET_NOT_FOUND",
|
||||
message=f"Dataset not found: {request.dataset_id}.",
|
||||
details=(
|
||||
f"No dataset found with identifier "
|
||||
f"'{request.dataset_id}'. Use list_datasets to "
|
||||
"find valid dataset IDs."
|
||||
),
|
||||
suggestions=[
|
||||
"Check that you have access to this dataset",
|
||||
"Use the list_datasets tool to find available datasets",
|
||||
],
|
||||
),
|
||||
}
|
||||
success=False,
|
||||
)
|
||||
|
||||
# When no config is provided, return a default explore URL that opens
|
||||
# the dataset in Superset without a preconfigured chart.
|
||||
if request.config is None:
|
||||
await ctx.report_progress(4, 4, "URL generation complete")
|
||||
from superset.mcp_service.utils.url_utils import get_superset_base_url
|
||||
|
||||
base_url = get_superset_base_url()
|
||||
default_url = (
|
||||
f"{base_url}/explore/?datasource_type=table&datasource_id={dataset.id}"
|
||||
@@ -182,14 +208,15 @@ async def generate_explore_link(
|
||||
await ctx.info(
|
||||
"Default explore link generated: dataset_id=%s" % (request.dataset_id,)
|
||||
)
|
||||
return {
|
||||
"url": default_url,
|
||||
"form_data": {},
|
||||
"permalink_key": None,
|
||||
"form_data_key": None,
|
||||
"chart_type_label": None,
|
||||
"error": None,
|
||||
}
|
||||
return GenerateExploreLinkResponse(
|
||||
url=default_url,
|
||||
form_data={},
|
||||
permalink_key=None,
|
||||
form_data_key=None,
|
||||
chart_type_label=None,
|
||||
error=None,
|
||||
success=True,
|
||||
)
|
||||
|
||||
await ctx.report_progress(2, 4, "Converting configuration to form data")
|
||||
with event_logger.log_context(action="mcp.generate_explore_link.form_data"):
|
||||
@@ -199,14 +226,28 @@ async def generate_explore_link(
|
||||
# Normalize column names to match canonical dataset column names
|
||||
# This fixes case sensitivity issues (e.g., 'order_date' vs 'OrderDate')
|
||||
try:
|
||||
from superset.mcp_service.chart.validation.dataset_validator import (
|
||||
DatasetValidator,
|
||||
)
|
||||
|
||||
normalized_config = DatasetValidator.normalize_column_names(
|
||||
config, request.dataset_id
|
||||
)
|
||||
except (ImportError, AttributeError, KeyError, ValueError, TypeError):
|
||||
except (
|
||||
ImportError,
|
||||
AttributeError,
|
||||
KeyError,
|
||||
ValueError,
|
||||
TypeError,
|
||||
) as norm_err:
|
||||
logger.warning(
|
||||
"Column normalization failed for dataset_id=%s; falling back "
|
||||
"to caller-supplied config. %s: %s",
|
||||
request.dataset_id,
|
||||
type(norm_err).__name__,
|
||||
norm_err,
|
||||
)
|
||||
await ctx.warning(
|
||||
"Column normalization failed for dataset_id=%s; using config "
|
||||
"as-supplied. Chart may behave unexpectedly if column names "
|
||||
"differ in case." % (request.dataset_id,)
|
||||
)
|
||||
normalized_config = config
|
||||
|
||||
# Map config to form_data using shared utilities
|
||||
@@ -242,25 +283,24 @@ async def generate_explore_link(
|
||||
await ctx.warning(
|
||||
"Explore link validation failed: error=%s" % (compile_result.error,)
|
||||
)
|
||||
error_payload: Dict[str, Any]
|
||||
if compile_result.error_obj is not None:
|
||||
error_payload = compile_result.error_obj.model_dump()
|
||||
error_payload = compile_result.error_obj
|
||||
else:
|
||||
error_payload = {
|
||||
"error_type": "validation_error",
|
||||
"message": "Explore link validation failed",
|
||||
"details": compile_result.error or "",
|
||||
"error_code": compile_result.error_code,
|
||||
"suggestions": [],
|
||||
}
|
||||
return {
|
||||
"url": "",
|
||||
"form_data": form_data,
|
||||
"permalink_key": None,
|
||||
"form_data_key": None,
|
||||
"chart_type_label": None,
|
||||
"error": error_payload,
|
||||
}
|
||||
error_payload = ChartGenerationError(
|
||||
error_type="validation_error",
|
||||
message="Explore link validation failed",
|
||||
details=compile_result.error or "",
|
||||
error_code=compile_result.error_code,
|
||||
)
|
||||
return GenerateExploreLinkResponse(
|
||||
url="",
|
||||
form_data=form_data,
|
||||
permalink_key=None,
|
||||
form_data_key=None,
|
||||
chart_type_label=None,
|
||||
error=error_payload,
|
||||
success=False,
|
||||
)
|
||||
|
||||
await ctx.report_progress(3, 4, "Generating explore URL")
|
||||
with event_logger.log_context(
|
||||
@@ -284,14 +324,15 @@ async def generate_explore_link(
|
||||
% (len(explore_url or ""), request.dataset_id, permalink_key, form_data_key)
|
||||
)
|
||||
|
||||
return {
|
||||
"url": explore_url,
|
||||
"form_data": form_data,
|
||||
"permalink_key": permalink_key,
|
||||
"form_data_key": form_data_key,
|
||||
"chart_type_label": get_table_chart_type_label(form_data.get("viz_type")),
|
||||
"error": None,
|
||||
}
|
||||
return GenerateExploreLinkResponse(
|
||||
url=explore_url,
|
||||
form_data=form_data,
|
||||
permalink_key=permalink_key,
|
||||
form_data_key=form_data_key,
|
||||
chart_type_label=get_table_chart_type_label(form_data.get("viz_type")),
|
||||
error=None,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
@@ -303,11 +344,23 @@ async def generate_explore_link(
|
||||
str(e),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"url": "",
|
||||
"form_data": {},
|
||||
"permalink_key": None,
|
||||
"form_data_key": None,
|
||||
"chart_type_label": None,
|
||||
"error": f"Failed to generate explore link: {str(e)}",
|
||||
}
|
||||
# ``details`` intentionally omits ``str(e)`` so internal info
|
||||
# (file paths, schema names) isn't echoed to the MCP response.
|
||||
# The raw exception is already captured in the server-side log
|
||||
# above via ``ctx.error``.
|
||||
return GenerateExploreLinkResponse(
|
||||
url="",
|
||||
form_data={},
|
||||
permalink_key=None,
|
||||
form_data_key=None,
|
||||
chart_type_label=None,
|
||||
error=ChartGenerationError(
|
||||
error_type="generation_failed",
|
||||
error_code="MCP_EXPLORE_GENERATION_FAILED",
|
||||
message="Failed to generate explore link",
|
||||
details=(
|
||||
"An unexpected error occurred; check server logs for details."
|
||||
),
|
||||
),
|
||||
success=False,
|
||||
)
|
||||
|
||||
@@ -29,18 +29,14 @@ import asyncio
|
||||
import base64
|
||||
import html as html_module
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
from authlib.jose.errors import (
|
||||
BadSignatureError,
|
||||
DecodeError,
|
||||
ExpiredTokenError,
|
||||
JoseError,
|
||||
)
|
||||
from authlib.jose.errors import JoseError
|
||||
from fastmcp.server.auth.auth import AccessToken
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
|
||||
@@ -491,7 +487,7 @@ class DetailedJWTVerifier(MCPJWTVerifier):
|
||||
# Step 1: Decode header and check algorithm
|
||||
try:
|
||||
header = self._decode_token_header(token)
|
||||
except (ValueError, DecodeError) as e:
|
||||
except ValueError as e:
|
||||
reason = "Malformed token header"
|
||||
_jwt_failure_reason.set(reason)
|
||||
logger.debug("Malformed token header: %s", e)
|
||||
@@ -511,7 +507,17 @@ class DetailedJWTVerifier(MCPJWTVerifier):
|
||||
_sanitize_for_log(token_alg),
|
||||
)
|
||||
return None
|
||||
if self.algorithm and token_alg != self.algorithm:
|
||||
# Require a pinned signing algorithm. Without one, the accepted
|
||||
# algorithm family would be whatever the verification key or the
|
||||
# underlying library permits; refuse rather than validating against
|
||||
# an unconstrained algorithm set. The production factory always
|
||||
# pins an algorithm, so this guards the directly-constructed case.
|
||||
if not self.algorithm:
|
||||
reason = "No signing algorithm pinned"
|
||||
_jwt_failure_reason.set(reason)
|
||||
logger.debug("Rejected token: verifier has no pinned signing algorithm")
|
||||
return None
|
||||
if token_alg != self.algorithm:
|
||||
reason = "Algorithm mismatch"
|
||||
_jwt_failure_reason.set(reason)
|
||||
logger.debug(
|
||||
@@ -566,18 +572,16 @@ class DetailedJWTVerifier(MCPJWTVerifier):
|
||||
# Step 3: Decode and verify signature
|
||||
try:
|
||||
claims = self.jwt.decode(token, verification_key)
|
||||
except BadSignatureError:
|
||||
reason = "Signature verification failed"
|
||||
_jwt_failure_reason.set(reason)
|
||||
return None
|
||||
except ExpiredTokenError:
|
||||
reason = "Token has expired (detected during decode)"
|
||||
_jwt_failure_reason.set(reason)
|
||||
return None
|
||||
except JoseError as e:
|
||||
reason = "Token decode failed"
|
||||
error_code = getattr(e, "error", None)
|
||||
if error_code == "bad_signature":
|
||||
reason = "Signature verification failed"
|
||||
elif error_code == "expired_token":
|
||||
reason = "Token has expired (detected during decode)"
|
||||
else:
|
||||
reason = "Token decode failed"
|
||||
logger.debug("Token decode failed: %s", e)
|
||||
_jwt_failure_reason.set(reason)
|
||||
logger.debug("Token decode failed: %s", e)
|
||||
return None
|
||||
|
||||
# Extract client ID for logging
|
||||
@@ -599,6 +603,24 @@ class DetailedJWTVerifier(MCPJWTVerifier):
|
||||
_sanitize_for_log(client_id),
|
||||
)
|
||||
return None
|
||||
# ``exp`` must be a finite real number. A non-numeric value would
|
||||
# raise ``TypeError`` on the comparison below, and a non-finite
|
||||
# float (e.g. ``inf`` parsed from a JSON ``1e309``) would overflow
|
||||
# the ``int(exp)`` cast later, raising ``OverflowError``. Both are
|
||||
# rejected here with a precise reason rather than escaping as a
|
||||
# generic failure (or, for the overflow, an uncaught 500).
|
||||
if (
|
||||
not isinstance(exp, (int, float))
|
||||
or isinstance(exp, bool)
|
||||
or not math.isfinite(exp)
|
||||
):
|
||||
reason = "Token has invalid expiration"
|
||||
_jwt_failure_reason.set(reason)
|
||||
logger.debug(
|
||||
"Token exp claim is not a finite number for client '%s'",
|
||||
_sanitize_for_log(client_id),
|
||||
)
|
||||
return None
|
||||
if exp < time.time():
|
||||
reason = "Token expired"
|
||||
_jwt_failure_reason.set(reason)
|
||||
@@ -703,7 +725,14 @@ class DetailedJWTVerifier(MCPJWTVerifier):
|
||||
claims=dict(claims),
|
||||
)
|
||||
|
||||
except (ValueError, JoseError, KeyError, AttributeError, TypeError) as e:
|
||||
except (
|
||||
ValueError,
|
||||
JoseError,
|
||||
KeyError,
|
||||
AttributeError,
|
||||
TypeError,
|
||||
OverflowError,
|
||||
) as e:
|
||||
reason = "Token validation failed"
|
||||
_jwt_failure_reason.set(reason)
|
||||
logger.debug("Token validation failed: %s", e)
|
||||
|
||||
@@ -20,7 +20,6 @@ import logging
|
||||
import secrets
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
|
||||
from authlib.jose.errors import JoseError
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from flask import Flask
|
||||
|
||||
@@ -33,6 +32,17 @@ from superset.mcp_service.jwt_verifier import DetailedJWTVerifier, MCPJWTVerifie
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MCPAuthConfigError(ValueError):
|
||||
"""Raised when MCP auth is enabled but configured in an unusable state.
|
||||
|
||||
Distinct from the generic build errors (e.g. malformed key material) that
|
||||
the auth bootstrap intentionally swallows: a configuration error of this
|
||||
kind must propagate so the MCP service fails to start rather than silently
|
||||
coming up without the protection the operator asked for.
|
||||
"""
|
||||
|
||||
|
||||
# MCP Service Configuration
|
||||
# Note: MCP_DEV_USERNAME MUST be configured in superset_config.py
|
||||
# There is no default value - the service will fail if not set
|
||||
@@ -360,6 +370,20 @@ def create_default_mcp_auth_factory(app: Flask) -> Optional[Any]:
|
||||
if not (auth_enabled or api_key_enabled):
|
||||
return None
|
||||
|
||||
# When JWT auth is enabled, an audience must be configured so issued tokens
|
||||
# are bound to this service. Without it the verifier accepts any otherwise
|
||||
# valid same-issuer token, regardless of which service it was minted for.
|
||||
# Treat a missing audience as a fatal configuration error so the service
|
||||
# fails to start instead of coming up in a permissive state — the
|
||||
# surrounding bootstrap would otherwise turn a None/raised provider into an
|
||||
# unauthenticated server.
|
||||
if auth_enabled and not app.config.get("MCP_JWT_AUDIENCE"):
|
||||
raise MCPAuthConfigError(
|
||||
"MCP_JWT_AUDIENCE must be set when MCP_AUTH_ENABLED is True so that "
|
||||
"tokens are bound to this service. Set MCP_JWT_AUDIENCE to the "
|
||||
"audience value your identity provider issues for the MCP service."
|
||||
)
|
||||
|
||||
jwt_verifier: Any | None = None
|
||||
|
||||
if auth_enabled:
|
||||
@@ -379,7 +403,7 @@ def create_default_mcp_auth_factory(app: Flask) -> Optional[Any]:
|
||||
public_key=public_key,
|
||||
secret=secret,
|
||||
)
|
||||
except (ValueError, JoseError):
|
||||
except Exception:
|
||||
# Do not log the exception — it may contain secrets (e.g., key material)
|
||||
logger.error("Failed to create MCP JWT verifier")
|
||||
if not api_key_enabled:
|
||||
|
||||
@@ -750,6 +750,7 @@ def _create_auth_provider(flask_app: Any) -> Any | None:
|
||||
):
|
||||
from superset.mcp_service.mcp_config import (
|
||||
create_default_mcp_auth_factory,
|
||||
MCPAuthConfigError,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -758,6 +759,12 @@ def _create_auth_provider(flask_app: Any) -> Any | None:
|
||||
"Auth provider created from default factory: %s",
|
||||
type(auth_provider).__name__ if auth_provider else "None",
|
||||
)
|
||||
except MCPAuthConfigError:
|
||||
# A misconfiguration that must fail closed: re-raise so the service
|
||||
# refuses to start rather than falling through to an unauthenticated
|
||||
# server. The message is operator-facing config guidance and carries
|
||||
# no secret material.
|
||||
raise
|
||||
except Exception:
|
||||
# Do not log the exception — it may contain secrets
|
||||
logger.error("Failed to create auth provider from default factory")
|
||||
|
||||
@@ -1979,7 +1979,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
if offset_metrics_df.empty:
|
||||
offset_metrics_df = pd.DataFrame(
|
||||
{
|
||||
col: [np.NaN]
|
||||
col: [np.nan]
|
||||
for col in join_keys + list(metrics_mapping.values())
|
||||
}
|
||||
)
|
||||
|
||||
@@ -184,6 +184,7 @@ class RLSPutSchema(Schema):
|
||||
metadata={"description": "tables_description"},
|
||||
required=False,
|
||||
allow_none=False,
|
||||
validate=Length(1),
|
||||
)
|
||||
roles = fields.List(
|
||||
fields.Integer(),
|
||||
|
||||
@@ -24,7 +24,15 @@ from flask_appbuilder.api.schemas import get_list_schema
|
||||
from flask_appbuilder.security.decorators import permission_name, protect
|
||||
from flask_appbuilder.security.sqla.models import RegisterUser, Role
|
||||
from flask_wtf.csrf import generate_csrf
|
||||
from marshmallow import EXCLUDE, fields, post_load, Schema, ValidationError
|
||||
from marshmallow import (
|
||||
EXCLUDE,
|
||||
fields,
|
||||
post_load,
|
||||
RAISE,
|
||||
Schema,
|
||||
validate,
|
||||
ValidationError,
|
||||
)
|
||||
from sqlalchemy import asc, desc
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -78,8 +86,33 @@ class ResourceSchema(PermissiveSchema):
|
||||
return data
|
||||
|
||||
|
||||
class RlsRuleSchema(PermissiveSchema):
|
||||
dataset = fields.Integer()
|
||||
class RlsRuleSchema(Schema):
|
||||
"""
|
||||
Schema for a single row-level security rule attached to a guest token.
|
||||
|
||||
Unlike the other guest-token schemas, this one rejects unknown fields
|
||||
instead of silently dropping them. A rule is scoped to a dataset only when
|
||||
it carries a valid positive integer ``dataset`` key; a rule with no
|
||||
``dataset`` is treated as global and its ``clause`` is applied to every
|
||||
dataset the embedded resource can reach (see ``get_guest_rls_filters``).
|
||||
Silently excluding an unexpected field -- most commonly a mistyped or
|
||||
legacy scope key such as ``datasource`` -- would therefore turn an intended
|
||||
dataset-scoped rule into a global one without any feedback to the caller.
|
||||
Raising on unknown fields surfaces the mistake as an HTTP 400 before a
|
||||
token is ever issued and keeps the accepted payload aligned with the
|
||||
documented ``RlsRule`` contract (``dataset`` and ``clause``).
|
||||
|
||||
For the same reason ``dataset`` is constrained to strict, positive
|
||||
integers: a falsy value such as ``0`` (or ``false``, which marshmallow
|
||||
coerces to ``0``) would pass a bare ``Integer`` field but then read as
|
||||
falsy in ``get_guest_rls_filters``, silently widening a scoped rule to
|
||||
every dataset.
|
||||
"""
|
||||
|
||||
class Meta: # pylint: disable=too-few-public-methods
|
||||
unknown = RAISE
|
||||
|
||||
dataset = fields.Integer(strict=True, validate=validate.Range(min=1))
|
||||
clause = fields.String(required=True) # todo other options?
|
||||
|
||||
|
||||
|
||||
@@ -14,10 +14,23 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import re
|
||||
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
# All C0 (U+0000–U+001F) and C1 (U+007F–U+009F) control characters.
|
||||
# Stripping every control char (including tab, LF, CR) keeps titles safe for
|
||||
# SMTP headers, Content-Disposition filenames, and headless-browser document.title.
|
||||
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
|
||||
|
||||
|
||||
def sanitize_title(title: str) -> str:
|
||||
"""Remove all C0/C1 control characters from a title string."""
|
||||
return _CONTROL_CHARS_RE.sub("", title)
|
||||
|
||||
|
||||
def get_filename(model_name: str, model_id: int, skip_id: bool = False) -> str:
|
||||
model_name = sanitize_title(model_name)
|
||||
slug = secure_filename(model_name)
|
||||
filename = slug if skip_id else f"{slug}_{model_id}"
|
||||
return filename if slug else str(model_id)
|
||||
|
||||
@@ -71,13 +71,21 @@ def _prophet_fit_and_predict( # pylint: disable=too-many-arguments
|
||||
)
|
||||
if df["ds"].dt.tz:
|
||||
df["ds"] = df["ds"].dt.tz_convert(None)
|
||||
model.fit(df)
|
||||
future = model.make_future_dataframe(periods=periods, freq=freq)
|
||||
forecast = model.predict(future)[["ds", "yhat", "yhat_lower", "yhat_upper"]]
|
||||
try:
|
||||
model.fit(df)
|
||||
future = model.make_future_dataframe(periods=periods, freq=freq)
|
||||
forecast = model.predict(future)[["ds", "yhat", "yhat_lower", "yhat_upper"]]
|
||||
except Exception as ex: # noqa: BLE001
|
||||
raise InvalidPostProcessingError(
|
||||
_(
|
||||
"Unable to generate forecast: %(error)s",
|
||||
error=str(ex),
|
||||
)
|
||||
) from ex
|
||||
return forecast.join(df.set_index("ds"), on="ds").set_index(["ds"])
|
||||
|
||||
|
||||
def prophet( # pylint: disable=too-many-arguments
|
||||
def prophet( # pylint: disable=too-many-arguments # noqa: C901
|
||||
df: DataFrame,
|
||||
time_grain: str,
|
||||
periods: int,
|
||||
@@ -136,6 +144,8 @@ def prophet( # pylint: disable=too-many-arguments
|
||||
raise InvalidPostProcessingError(_("DataFrame must include temporal column"))
|
||||
if len(df.columns) < 2:
|
||||
raise InvalidPostProcessingError(_("DataFrame include at least one series"))
|
||||
if len(df) < 2:
|
||||
raise InvalidPostProcessingError(_("Forecast requires at least 2 data points"))
|
||||
|
||||
target_df = DataFrame()
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ from pandas import DataFrame, NamedAgg
|
||||
from superset.constants import TimeGrain
|
||||
from superset.exceptions import InvalidPostProcessingError
|
||||
|
||||
_PANDAS_VERSION = tuple(int(x) for x in pd.__version__.split(".")[:2])
|
||||
|
||||
NUMPY_FUNCTIONS: dict[str, Callable[..., Any]] = {
|
||||
"average": np.average,
|
||||
"argmin": np.argmin,
|
||||
@@ -76,18 +78,18 @@ ALLOWLIST_CUMULATIVE_FUNCTIONS = (
|
||||
)
|
||||
|
||||
PROPHET_TIME_GRAIN_MAP: dict[str, str] = {
|
||||
TimeGrain.SECOND: "S",
|
||||
TimeGrain.SECOND: "s",
|
||||
TimeGrain.MINUTE: "min",
|
||||
TimeGrain.FIVE_MINUTES: "5min",
|
||||
TimeGrain.TEN_MINUTES: "10min",
|
||||
TimeGrain.FIFTEEN_MINUTES: "15min",
|
||||
TimeGrain.THIRTY_MINUTES: "30min",
|
||||
TimeGrain.HOUR: "H",
|
||||
TimeGrain.HOUR: "h",
|
||||
TimeGrain.DAY: "D",
|
||||
TimeGrain.WEEK: "W",
|
||||
TimeGrain.MONTH: "M",
|
||||
TimeGrain.QUARTER: "Q",
|
||||
TimeGrain.YEAR: "A",
|
||||
TimeGrain.MONTH: "ME" if _PANDAS_VERSION >= (2, 2) else "M",
|
||||
TimeGrain.QUARTER: "QE" if _PANDAS_VERSION >= (2, 2) else "Q",
|
||||
TimeGrain.YEAR: "YE" if _PANDAS_VERSION >= (2, 2) else "A",
|
||||
TimeGrain.WEEK_STARTING_SUNDAY: "W-SUN",
|
||||
TimeGrain.WEEK_STARTING_MONDAY: "W-MON",
|
||||
TimeGrain.WEEK_ENDING_SATURDAY: "W-SAT",
|
||||
|
||||
@@ -342,10 +342,10 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
selenium_animation_wait = app.config[
|
||||
"SCREENSHOT_SELENIUM_ANIMATION_WAIT"
|
||||
]
|
||||
logger.debug(
|
||||
"Wait %i seconds for chart animation", selenium_animation_wait
|
||||
)
|
||||
page.wait_for_timeout(selenium_animation_wait * 1000)
|
||||
# The animation wait is applied later, after the loading
|
||||
# spinners clear: ECharts only starts its draw animation once
|
||||
# data has arrived and the spinner is gone, so waiting here
|
||||
# (before charts finish loading) would not cover it.
|
||||
logger.debug(
|
||||
"Taking a PNG screenshot of url %s as user %s",
|
||||
url,
|
||||
@@ -444,6 +444,13 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
self._screenshot_load_wait,
|
||||
)
|
||||
raise
|
||||
# Wait for chart animations (e.g. ECharts) to finish
|
||||
# after the spinners clear. The draw animation only
|
||||
# starts once data arrives and the spinner is gone, so
|
||||
# without this wait a slow-loading chart is captured
|
||||
# mid-render.
|
||||
if selenium_animation_wait > 0:
|
||||
page.wait_for_timeout(selenium_animation_wait * 1000)
|
||||
img = WebDriverPlaywright._get_screenshot(
|
||||
page, element, element_name
|
||||
)
|
||||
@@ -467,6 +474,12 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
self._screenshot_load_wait,
|
||||
)
|
||||
raise
|
||||
# Wait for chart animations (e.g. ECharts) to finish after
|
||||
# the spinners clear. The draw animation only starts once
|
||||
# data arrives and the spinner is gone, so without this wait
|
||||
# a slow-loading chart is captured mid-render.
|
||||
if selenium_animation_wait > 0:
|
||||
page.wait_for_timeout(selenium_animation_wait * 1000)
|
||||
img = WebDriverPlaywright._get_screenshot(
|
||||
page, element, element_name
|
||||
)
|
||||
|
||||
+11
-1
@@ -52,6 +52,7 @@ from superset import (
|
||||
is_feature_enabled,
|
||||
security_manager,
|
||||
)
|
||||
from superset.config import _THEME_DARK_BASE, _THEME_DEFAULT_BASE
|
||||
from superset.connectors.sqla import models
|
||||
from superset.daos.theme import ThemeDAO
|
||||
from superset.db_engine_specs import get_available_engine_specs
|
||||
@@ -375,9 +376,18 @@ def get_theme_bootstrap_data() -> dict[str, Any]:
|
||||
# Check if UI theme administration is enabled
|
||||
enable_ui_admin = app.config.get("ENABLE_UI_THEME_ADMINISTRATION", False)
|
||||
|
||||
# Get config themes to use as fallback
|
||||
# Get config themes, deep-merging partial user overrides with built-in defaults
|
||||
# so that unspecified token fields fall back gracefully.
|
||||
config_theme_default = get_config_value("THEME_DEFAULT")
|
||||
if config_theme_default:
|
||||
config_theme_default = _merge_theme_dicts(
|
||||
dict(_THEME_DEFAULT_BASE), dict(config_theme_default)
|
||||
)
|
||||
config_theme_dark = get_config_value("THEME_DARK")
|
||||
if config_theme_dark:
|
||||
config_theme_dark = _merge_theme_dicts(
|
||||
dict(_THEME_DARK_BASE), dict(config_theme_dark)
|
||||
)
|
||||
|
||||
if enable_ui_admin:
|
||||
# Try to load themes from database
|
||||
|
||||
@@ -1626,6 +1626,32 @@ class DeckGLMultiLayer(BaseViz):
|
||||
is_timeseries = False
|
||||
credits = '<a href="https://uber.github.io/deck.gl/">deck.gl</a>'
|
||||
|
||||
@staticmethod
|
||||
def _merge_filter_metadata(
|
||||
*filter_groups: list[dict[str, Any]] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Merge multiple filter metadata lists, de-duplicating identical entries.
|
||||
|
||||
Used to combine the applied/rejected filter metadata reported by each
|
||||
child layer into a single list for the multi-layer chart payload.
|
||||
"""
|
||||
merged_filters: list[dict[str, Any]] = []
|
||||
seen_filters: set[str] = set()
|
||||
|
||||
for filters in filter_groups:
|
||||
for filter_metadata in filters or []:
|
||||
if not isinstance(filter_metadata, dict):
|
||||
continue
|
||||
|
||||
cache_key = json.dumps(filter_metadata, sort_keys=True)
|
||||
if cache_key in seen_filters:
|
||||
continue
|
||||
|
||||
merged_filters.append(filter_metadata)
|
||||
seen_filters.add(cache_key)
|
||||
|
||||
return merged_filters
|
||||
|
||||
@deprecated(deprecated_in="3.0")
|
||||
def query_obj(self) -> QueryObjectDict:
|
||||
return {}
|
||||
@@ -1726,6 +1752,8 @@ class DeckGLMultiLayer(BaseViz):
|
||||
slices = db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all()
|
||||
|
||||
features: dict[str, list[Any]] = {}
|
||||
self.applied_filters = []
|
||||
self.rejected_filters = []
|
||||
|
||||
for layer_index, slc in enumerate(slices):
|
||||
form_data = slc.form_data
|
||||
@@ -1738,6 +1766,15 @@ class DeckGLMultiLayer(BaseViz):
|
||||
|
||||
viz_instance = viz_class(datasource=slc.datasource, form_data=form_data)
|
||||
payload = viz_instance.get_payload()
|
||||
if payload:
|
||||
self.applied_filters = self._merge_filter_metadata(
|
||||
self.applied_filters,
|
||||
payload.get("applied_filters"),
|
||||
)
|
||||
self.rejected_filters = self._merge_filter_metadata(
|
||||
self.rejected_filters,
|
||||
payload.get("rejected_filters"),
|
||||
)
|
||||
|
||||
if (
|
||||
payload
|
||||
@@ -1755,6 +1792,25 @@ class DeckGLMultiLayer(BaseViz):
|
||||
"slices": [slc.data for slc in slices if slc.data is not None],
|
||||
}
|
||||
|
||||
@deprecated(deprecated_in="3.0")
|
||||
def get_payload(self, query_obj: QueryObjectDict | None = None) -> VizPayload:
|
||||
"""Extend the base payload with merged child-layer filter metadata.
|
||||
|
||||
The applied/rejected filter metadata collected from each sub-slice in
|
||||
``get_data`` is merged into the base payload so dashboard filter badges
|
||||
reflect the filters applied across all layers.
|
||||
"""
|
||||
payload = super().get_payload(query_obj)
|
||||
payload["applied_filters"] = self._merge_filter_metadata(
|
||||
payload.get("applied_filters"),
|
||||
self.applied_filters,
|
||||
)
|
||||
payload["rejected_filters"] = self._merge_filter_metadata(
|
||||
payload.get("rejected_filters"),
|
||||
self.rejected_filters,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
class BaseDeckGLViz(BaseViz):
|
||||
"""Base class for deck.gl visualizations"""
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import itertools
|
||||
from unittest.mock import MagicMock, patch # noqa: F401
|
||||
|
||||
import pytest
|
||||
@@ -348,22 +347,21 @@ class TestExportDashboardsCommand(SupersetTestCase):
|
||||
}
|
||||
|
||||
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
|
||||
@patch("superset.commands.dashboard.export.suffix")
|
||||
def test_append_charts(self, mock_suffix):
|
||||
def test_append_charts(self):
|
||||
"""Test that orphaned charts are added to the dashboard position"""
|
||||
# return deterministic IDs
|
||||
mock_suffix.side_effect = (str(i) for i in itertools.count(1))
|
||||
|
||||
# IDs are deterministic: charts are keyed by their UUID and rows are
|
||||
# numbered by their position within the grid.
|
||||
position = get_default_position("example")
|
||||
chart_1 = (
|
||||
db.session.query(Slice).filter_by(slice_name="World's Population").one()
|
||||
)
|
||||
chart_1_key = f"CHART-{chart_1.uuid}"
|
||||
new_position = append_charts(position, {chart_1})
|
||||
assert new_position == {
|
||||
"DASHBOARD_VERSION_KEY": "v2",
|
||||
"ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"},
|
||||
"GRID_ID": {
|
||||
"children": ["ROW-N-2"],
|
||||
"children": ["ROW-N-0"],
|
||||
"id": "GRID_ID",
|
||||
"parents": ["ROOT_ID"],
|
||||
"type": "GRID",
|
||||
@@ -373,16 +371,16 @@ class TestExportDashboardsCommand(SupersetTestCase):
|
||||
"meta": {"text": "example"},
|
||||
"type": "HEADER",
|
||||
},
|
||||
"ROW-N-2": {
|
||||
"children": ["CHART-1"],
|
||||
"id": "ROW-N-2",
|
||||
"ROW-N-0": {
|
||||
"children": [chart_1_key],
|
||||
"id": "ROW-N-0",
|
||||
"meta": {"0": "ROOT_ID", "background": "BACKGROUND_TRANSPARENT"},
|
||||
"type": "ROW",
|
||||
"parents": ["ROOT_ID", "GRID_ID"],
|
||||
},
|
||||
"CHART-1": {
|
||||
chart_1_key: {
|
||||
"children": [],
|
||||
"id": "CHART-1",
|
||||
"id": chart_1_key,
|
||||
"meta": {
|
||||
"chartId": chart_1.id,
|
||||
"height": 50,
|
||||
@@ -391,19 +389,18 @@ class TestExportDashboardsCommand(SupersetTestCase):
|
||||
"width": 4,
|
||||
},
|
||||
"type": "CHART",
|
||||
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-2"],
|
||||
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-0"],
|
||||
},
|
||||
}
|
||||
|
||||
chart_2 = (
|
||||
db.session.query(Slice).filter_by(slice_name="World's Population").one()
|
||||
)
|
||||
chart_2 = db.session.query(Slice).filter_by(slice_name="Growth Rate").one()
|
||||
chart_2_key = f"CHART-{chart_2.uuid}"
|
||||
new_position = append_charts(new_position, {chart_2})
|
||||
assert new_position == {
|
||||
"DASHBOARD_VERSION_KEY": "v2",
|
||||
"ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"},
|
||||
"GRID_ID": {
|
||||
"children": ["ROW-N-2", "ROW-N-4"],
|
||||
"children": ["ROW-N-0", "ROW-N-1"],
|
||||
"id": "GRID_ID",
|
||||
"parents": ["ROOT_ID"],
|
||||
"type": "GRID",
|
||||
@@ -413,23 +410,23 @@ class TestExportDashboardsCommand(SupersetTestCase):
|
||||
"meta": {"text": "example"},
|
||||
"type": "HEADER",
|
||||
},
|
||||
"ROW-N-2": {
|
||||
"children": ["CHART-1"],
|
||||
"id": "ROW-N-2",
|
||||
"ROW-N-0": {
|
||||
"children": [chart_1_key],
|
||||
"id": "ROW-N-0",
|
||||
"meta": {"0": "ROOT_ID", "background": "BACKGROUND_TRANSPARENT"},
|
||||
"type": "ROW",
|
||||
"parents": ["ROOT_ID", "GRID_ID"],
|
||||
},
|
||||
"ROW-N-4": {
|
||||
"children": ["CHART-3"],
|
||||
"id": "ROW-N-4",
|
||||
"ROW-N-1": {
|
||||
"children": [chart_2_key],
|
||||
"id": "ROW-N-1",
|
||||
"meta": {"0": "ROOT_ID", "background": "BACKGROUND_TRANSPARENT"},
|
||||
"type": "ROW",
|
||||
"parents": ["ROOT_ID", "GRID_ID"],
|
||||
},
|
||||
"CHART-1": {
|
||||
chart_1_key: {
|
||||
"children": [],
|
||||
"id": "CHART-1",
|
||||
"id": chart_1_key,
|
||||
"meta": {
|
||||
"chartId": chart_1.id,
|
||||
"height": 50,
|
||||
@@ -438,29 +435,29 @@ class TestExportDashboardsCommand(SupersetTestCase):
|
||||
"width": 4,
|
||||
},
|
||||
"type": "CHART",
|
||||
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-2"],
|
||||
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-0"],
|
||||
},
|
||||
"CHART-3": {
|
||||
chart_2_key: {
|
||||
"children": [],
|
||||
"id": "CHART-3",
|
||||
"id": chart_2_key,
|
||||
"meta": {
|
||||
"chartId": chart_2.id,
|
||||
"height": 50,
|
||||
"sliceName": "World's Population",
|
||||
"sliceName": "Growth Rate",
|
||||
"uuid": str(chart_2.uuid),
|
||||
"width": 4,
|
||||
},
|
||||
"type": "CHART",
|
||||
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-4"],
|
||||
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-1"],
|
||||
},
|
||||
}
|
||||
|
||||
position = {"DASHBOARD_VERSION_KEY": "v2"}
|
||||
new_position = append_charts(position, [chart_1, chart_2])
|
||||
assert new_position == {
|
||||
"CHART-5": {
|
||||
chart_1_key: {
|
||||
"children": [],
|
||||
"id": "CHART-5",
|
||||
"id": chart_1_key,
|
||||
"meta": {
|
||||
"chartId": chart_1.id,
|
||||
"height": 50,
|
||||
@@ -470,13 +467,13 @@ class TestExportDashboardsCommand(SupersetTestCase):
|
||||
},
|
||||
"type": "CHART",
|
||||
},
|
||||
"CHART-6": {
|
||||
chart_2_key: {
|
||||
"children": [],
|
||||
"id": "CHART-6",
|
||||
"id": chart_2_key,
|
||||
"meta": {
|
||||
"chartId": chart_2.id,
|
||||
"height": 50,
|
||||
"sliceName": "World's Population",
|
||||
"sliceName": "Growth Rate",
|
||||
"uuid": str(chart_2.uuid),
|
||||
"width": 4,
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@ import tests.integration_tests.test_app # noqa: F401
|
||||
import superset.viz as viz
|
||||
from flask import current_app
|
||||
from superset.exceptions import QueryObjectValidationError, SpatialException
|
||||
from superset.utils.core import DTTM_ALIAS
|
||||
from superset.utils.core import DTTM_ALIAS, ExtraFiltersReasonType
|
||||
from superset.utils.pandas_postprocessing.utils import FLAT_COLUMN_SEPARATOR
|
||||
from tests.conftest import with_config
|
||||
|
||||
@@ -1849,6 +1849,79 @@ class TestDeckGLMultiLayer(SupersetTestCase):
|
||||
assert len(result["slices"]) == 1
|
||||
assert result["slices"][0] == slice_1.data
|
||||
|
||||
@with_config({"MAPBOX_API_KEY": "test_key"})
|
||||
@patch("superset.viz.viz_types")
|
||||
@patch("superset.db.session")
|
||||
def test_get_payload_includes_subslice_filter_metadata(
|
||||
self,
|
||||
mock_db_session,
|
||||
mock_viz_types,
|
||||
):
|
||||
"""Test deck.gl multi-layer payload includes child filter metadata."""
|
||||
datasource = self.get_datasource_mock()
|
||||
|
||||
slice_1 = Mock()
|
||||
slice_1.form_data = {"viz_type": "deck_scatter"}
|
||||
slice_1.data = {"features": [{"type": "Feature"}]}
|
||||
slice_1.datasource = datasource
|
||||
|
||||
slice_2 = Mock()
|
||||
slice_2.form_data = {"viz_type": "deck_path"}
|
||||
slice_2.data = {"features": [{"type": "Feature"}]}
|
||||
slice_2.datasource = datasource
|
||||
|
||||
mock_db_session.query.return_value.filter.return_value.all.return_value = [
|
||||
slice_1,
|
||||
slice_2,
|
||||
]
|
||||
|
||||
mock_scatter_viz_class = Mock()
|
||||
mock_scatter_viz_instance = Mock()
|
||||
mock_scatter_viz_instance.get_payload.return_value = {
|
||||
"data": {"features": [{"id": 1}]},
|
||||
"applied_filters": [{"column": "Latitude"}],
|
||||
"rejected_filters": [],
|
||||
}
|
||||
mock_scatter_viz_class.return_value = mock_scatter_viz_instance
|
||||
|
||||
mock_path_viz_class = Mock()
|
||||
mock_path_viz_instance = Mock()
|
||||
mock_path_viz_instance.get_payload.return_value = {
|
||||
"data": {"features": [{"id": 2}]},
|
||||
"applied_filters": [
|
||||
{"column": "Latitude"},
|
||||
{"column": "Longitude"},
|
||||
],
|
||||
"rejected_filters": [
|
||||
{
|
||||
"column": "Country",
|
||||
"reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE,
|
||||
},
|
||||
],
|
||||
}
|
||||
mock_path_viz_class.return_value = mock_path_viz_instance
|
||||
|
||||
mock_viz_types.get.side_effect = lambda viz_type: {
|
||||
"deck_scatter": mock_scatter_viz_class,
|
||||
"deck_path": mock_path_viz_class,
|
||||
}.get(viz_type)
|
||||
|
||||
test_viz = viz.DeckGLMultiLayer(datasource, {"deck_slices": [1, 2]})
|
||||
test_viz.get_df_payload = Mock(return_value={"df": pd.DataFrame()})
|
||||
|
||||
result = test_viz.get_payload()
|
||||
|
||||
assert result["applied_filters"] == [
|
||||
{"column": "Latitude"},
|
||||
{"column": "Longitude"},
|
||||
]
|
||||
assert result["rejected_filters"] == [
|
||||
{
|
||||
"column": "Country",
|
||||
"reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE,
|
||||
},
|
||||
]
|
||||
|
||||
@with_config({"MAPBOX_API_KEY": "test_key"})
|
||||
def test_get_data_empty_deck_slices(self):
|
||||
"""Test get_data method with empty deck_slices."""
|
||||
|
||||
@@ -152,6 +152,97 @@ def test_update_rls_rule_allowed_when_datasource_access() -> None:
|
||||
assert command._properties["tables"] == tables
|
||||
|
||||
|
||||
def test_update_rls_rule_partial_update_preserves_tables_and_roles() -> None:
|
||||
"""A partial update without tables/roles must not clear those bindings.
|
||||
|
||||
When the request body omits ``tables``/``roles``, validate() must not add
|
||||
those keys to the properties passed to the DAO, so the existing bindings
|
||||
are left untouched instead of being overwritten with empty lists.
|
||||
"""
|
||||
rule = MagicMock()
|
||||
rule.tables = _mock_tables(1)
|
||||
with (
|
||||
patch(
|
||||
"superset.commands.security.update.RLSDAO.find_by_id",
|
||||
return_value=rule,
|
||||
),
|
||||
patch(
|
||||
"superset.commands.security.update.populate_roles",
|
||||
) as populate_roles,
|
||||
patch("superset.commands.security.update.db.session.query") as query,
|
||||
patch(
|
||||
"superset.commands.security.utils.security_manager.can_access_datasource",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
command = UpdateRLSRuleCommand(1, {"name": "new name"})
|
||||
command.validate()
|
||||
|
||||
# Omitted relationships are not resolved or written back.
|
||||
populate_roles.assert_not_called()
|
||||
query.assert_not_called()
|
||||
assert "tables" not in command._properties
|
||||
assert "roles" not in command._properties
|
||||
assert command._properties["name"] == "new name"
|
||||
|
||||
|
||||
def test_update_rls_rule_only_roles_present_does_not_touch_tables() -> None:
|
||||
"""Updating only ``roles`` must not resolve or overwrite ``tables``."""
|
||||
rule = MagicMock()
|
||||
rule.tables = _mock_tables(1)
|
||||
with (
|
||||
patch(
|
||||
"superset.commands.security.update.RLSDAO.find_by_id",
|
||||
return_value=rule,
|
||||
),
|
||||
patch(
|
||||
"superset.commands.security.update.populate_roles",
|
||||
return_value=["resolved-role"],
|
||||
) as populate_roles,
|
||||
patch("superset.commands.security.update.db.session.query") as query,
|
||||
patch(
|
||||
"superset.commands.security.utils.security_manager.can_access_datasource",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
command = UpdateRLSRuleCommand(1, {"roles": [1]})
|
||||
command.validate()
|
||||
|
||||
populate_roles.assert_called_once()
|
||||
query.assert_not_called()
|
||||
assert command._properties["roles"] == ["resolved-role"]
|
||||
assert "tables" not in command._properties
|
||||
|
||||
|
||||
def test_update_rls_rule_partial_update_enforces_access_on_existing_tables() -> None:
|
||||
"""A partial update that omits ``tables`` still enforces datasource access.
|
||||
|
||||
The rule's existing table bindings must be authorized so a caller cannot
|
||||
edit a rule tied to datasources they cannot access by simply omitting
|
||||
``tables`` from the payload.
|
||||
"""
|
||||
rule = MagicMock()
|
||||
rule.tables = _mock_tables(1)
|
||||
with (
|
||||
patch(
|
||||
"superset.commands.security.update.RLSDAO.find_by_id",
|
||||
return_value=rule,
|
||||
),
|
||||
patch("superset.commands.security.update.db.session.query") as query,
|
||||
patch(
|
||||
"superset.commands.security.utils.security_manager.can_access_datasource",
|
||||
return_value=False,
|
||||
) as can_access,
|
||||
):
|
||||
command = UpdateRLSRuleCommand(1, {"name": "new name"})
|
||||
with pytest.raises(RLSDatasourceForbiddenError):
|
||||
command.validate()
|
||||
|
||||
# Access is checked against the rule's existing tables, not a submitted set.
|
||||
can_access.assert_called_once_with(datasource=rule.tables[0])
|
||||
query.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_rls_rule_forbidden_when_no_datasource_access() -> None:
|
||||
tables = _mock_tables(1)
|
||||
rule = MagicMock()
|
||||
|
||||
@@ -312,3 +312,40 @@ def test_full_setting(
|
||||
assert dttm_col.is_dttm
|
||||
assert dttm_col.python_date_format == "epoch_s"
|
||||
assert dttm_col.expression == "CAST(dttm as INTEGER)"
|
||||
|
||||
|
||||
def test_sync_theme_logo_href() -> None:
|
||||
"""
|
||||
Verify LOGO_TARGET_PATH is wired into a theme's brandLogoHref.
|
||||
|
||||
THEME_DEFAULT is built before superset_config.py overrides load, so the link
|
||||
is re-synced afterwards via sync_theme_logo_href. A provided LOGO_TARGET_PATH
|
||||
must update brandLogoHref; None must leave the existing value untouched.
|
||||
"""
|
||||
from copy import deepcopy
|
||||
|
||||
from superset.config import sync_theme_logo_href, THEME_DEFAULT
|
||||
|
||||
# A user-provided LOGO_TARGET_PATH propagates to the logo link.
|
||||
theme = deepcopy(THEME_DEFAULT)
|
||||
theme["token"]["brandLogoHref"] = "/"
|
||||
sync_theme_logo_href(theme, "https://custom.url")
|
||||
assert theme["token"]["brandLogoHref"] == "https://custom.url"
|
||||
|
||||
# The default (None) leaves the existing link untouched.
|
||||
default_theme = deepcopy(THEME_DEFAULT)
|
||||
default_theme["token"]["brandLogoHref"] = "/"
|
||||
sync_theme_logo_href(default_theme, None)
|
||||
assert default_theme["token"]["brandLogoHref"] == "/"
|
||||
|
||||
# A disabled theme (None) is a no-op rather than an error.
|
||||
sync_theme_logo_href(None, "https://custom.url")
|
||||
|
||||
|
||||
def test_theme_default_logo_defaults() -> None:
|
||||
"""With the shipped defaults, brandLogoHref is "/" and brandLogoUrl is APP_ICON."""
|
||||
from superset import config
|
||||
|
||||
assert config.LOGO_TARGET_PATH is None
|
||||
assert config.THEME_DEFAULT["token"]["brandLogoHref"] == "/"
|
||||
assert config.THEME_DEFAULT["token"]["brandLogoUrl"] == config.APP_ICON
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 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 uuid
|
||||
|
||||
from superset.commands.dashboard import export as export_module
|
||||
|
||||
|
||||
class DummySlice:
|
||||
def __init__(self, id_: int, slice_uuid: uuid.UUID, slice_name: str = "chart"):
|
||||
self.id = id_
|
||||
self.uuid = slice_uuid
|
||||
self.slice_name = slice_name
|
||||
|
||||
|
||||
def test_append_deterministic_fields():
|
||||
# start with a default position (has ROOT_ID and GRID_ID)
|
||||
position = export_module.get_default_position("test")
|
||||
|
||||
# create two dummy slices with known UUIDs
|
||||
u1 = uuid.UUID("11111111-1111-1111-1111-111111111111")
|
||||
u2 = uuid.UUID("22222222-2222-2222-2222-222222222222")
|
||||
s1 = DummySlice(101, u1, "Chart One")
|
||||
s2 = DummySlice(102, u2, "Chart Two")
|
||||
charts = {s1, s2}
|
||||
|
||||
new_pos = export_module.append_charts(position, charts)
|
||||
|
||||
# chart keys should be CHART-<uuid>
|
||||
expected_keys = {f"CHART-{str(u1)}", f"CHART-{str(u2)}"}
|
||||
|
||||
# row key should be ROW-N-<row number>
|
||||
# expected row number is 0 since we started with only ROOT_ID and GRID_ID
|
||||
expected_row = "ROW-N-0"
|
||||
|
||||
assert expected_row in new_pos, "expected row key in position"
|
||||
for k in expected_keys:
|
||||
assert k in new_pos, f"expected chart key {k} in position"
|
||||
|
||||
# Ensure meta.uuid equals the chart uuid and chartId equals id
|
||||
for chart in (s1, s2):
|
||||
key = f"CHART-{str(chart.uuid)}"
|
||||
meta = new_pos[key]["meta"]
|
||||
assert meta["uuid"] == str(chart.uuid)
|
||||
assert meta["chartId"] == chart.id
|
||||
assert meta["sliceName"] == chart.slice_name
|
||||
@@ -430,6 +430,30 @@ def test_get_default_catalog(mocker: MockerFixture) -> None:
|
||||
assert BigQueryEngineSpec.get_default_catalog(database) == "project"
|
||||
|
||||
|
||||
def test_get_time_partition_column_uses_catalog_in_table_reference(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
Test that partition metadata lookup preserves the BigQuery project.
|
||||
"""
|
||||
from superset.db_engine_specs.bigquery import BigQueryEngineSpec
|
||||
|
||||
database = mock.Mock()
|
||||
engine = mock.MagicMock()
|
||||
get_engine = mocker.patch.object(BigQueryEngineSpec, "get_engine")
|
||||
get_engine.return_value.__enter__.return_value = engine
|
||||
client = mocker.patch.object(BigQueryEngineSpec, "_get_client").return_value
|
||||
client.get_table.return_value.time_partitioning.field = "ds"
|
||||
|
||||
result = BigQueryEngineSpec.get_time_partition_column(
|
||||
database,
|
||||
Table("my_table", "my_dataset", "other_project"),
|
||||
)
|
||||
|
||||
assert result == "ds"
|
||||
client.get_table.assert_called_once_with("other_project.my_dataset.my_table")
|
||||
|
||||
|
||||
def test_adjust_engine_params_catalog_as_host() -> None:
|
||||
"""
|
||||
Test passing a custom catalog.
|
||||
|
||||
@@ -1480,8 +1480,9 @@ class TestUpdateChartDatasetIdIntegration:
|
||||
"superset.commands.chart.update.UpdateChartCommand",
|
||||
new_callable=Mock,
|
||||
)
|
||||
@patch(
|
||||
"superset.mcp_service.chart.tool.update_chart._validate_update_against_dataset",
|
||||
@patch.object(
|
||||
update_chart_module,
|
||||
"_validate_update_against_dataset",
|
||||
return_value=None,
|
||||
)
|
||||
@patch("superset.daos.chart.ChartDAO.find_by_id", new_callable=Mock)
|
||||
|
||||
@@ -73,6 +73,14 @@ def mock_auth():
|
||||
yield mock_get_user
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_event_logger():
|
||||
"""Skip event-logger DB writes so a bad logs FK doesn't poison the
|
||||
session for FastMCP's response serialization on the success path."""
|
||||
with patch("superset.utils.log.DBEventLogger.log", return_value=None):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_dataset_access_granted():
|
||||
"""Grant dataset access by default; tests that need a denial override this."""
|
||||
@@ -167,14 +175,16 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
assert result.data["permalink_key"] == "test_permalink_key"
|
||||
assert result.data["form_data_key"] is None
|
||||
assert result.data["chart_type_label"] == "table chart"
|
||||
assert result.structured_content["permalink_key"] == "test_permalink_key"
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert result.structured_content["chart_type_label"] == "table chart"
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
@@ -204,14 +214,16 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
assert result.data["permalink_key"] == "test_permalink_key"
|
||||
assert result.data["form_data_key"] is None
|
||||
assert result.data["chart_type_label"] == "table chart"
|
||||
assert result.structured_content["permalink_key"] == "test_permalink_key"
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert result.structured_content["chart_type_label"] == "table chart"
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
@@ -233,8 +245,13 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.data["chart_type_label"] == "interactive table chart"
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.structured_content["chart_type_label"]
|
||||
== "interactive table chart"
|
||||
)
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
@@ -264,12 +281,14 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
assert result.data["chart_type_label"] is None
|
||||
assert result.structured_content["chart_type_label"] is None
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
@@ -292,9 +311,11 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
|
||||
@@ -324,9 +345,11 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
|
||||
@@ -354,9 +377,11 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
|
||||
@@ -392,13 +417,15 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/?form_data_key=fallback_form_data_key"
|
||||
)
|
||||
assert result.data["form_data_key"] == "fallback_form_data_key"
|
||||
assert result.data["permalink_key"] is None
|
||||
assert (
|
||||
result.structured_content["form_data_key"] == "fallback_form_data_key"
|
||||
)
|
||||
assert result.structured_content["permalink_key"] is None
|
||||
mock_create_form_data.assert_called_once()
|
||||
|
||||
@patch(_PERMALINK_PATCH)
|
||||
@@ -434,9 +461,10 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/?datasource_type=table&datasource_id=1"
|
||||
)
|
||||
|
||||
@@ -473,9 +501,10 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/?form_data_key=lock_fallback_key"
|
||||
)
|
||||
|
||||
@@ -505,9 +534,11 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
|
||||
@@ -543,9 +574,11 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
|
||||
@@ -595,10 +628,11 @@ class TestGenerateExploreLink:
|
||||
|
||||
# All URLs should follow the same permalink format
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
assert result.structured_content["success"] is True
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
@@ -621,9 +655,10 @@ class TestGenerateExploreLink:
|
||||
result = await client.call_tool(
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
|
||||
@@ -661,9 +696,11 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
|
||||
@@ -708,8 +745,9 @@ class TestGenerateExploreLink:
|
||||
f"http://localhost:9001/explore/?datasource_type=table"
|
||||
f"&datasource_id={dataset_id}"
|
||||
)
|
||||
assert result.data["error"] is None
|
||||
assert result.data["url"] == expected_url
|
||||
assert result.structured_content["error"] is None
|
||||
assert result.structured_content["success"] is True
|
||||
assert result.structured_content["url"] == expected_url
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
@@ -744,12 +782,19 @@ class TestGenerateExploreLink:
|
||||
)
|
||||
|
||||
# Should return error response with empty URL
|
||||
assert result.data["url"] == ""
|
||||
assert result.data["form_data"] == {}
|
||||
assert result.data["form_data_key"] is None
|
||||
assert result.data["permalink_key"] is None
|
||||
assert result.data["chart_type_label"] is None
|
||||
assert "Invalid config structure" in result.data["error"]
|
||||
assert result.structured_content["url"] == ""
|
||||
assert result.structured_content["form_data"] == {}
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert result.structured_content["permalink_key"] is None
|
||||
assert result.structured_content["chart_type_label"] is None
|
||||
assert result.structured_content["success"] is False
|
||||
error = result.structured_content["error"]
|
||||
assert error["error_type"] == "generation_failed"
|
||||
# ``details`` is the static, sanitized message; the raw
|
||||
# exception text ("Invalid config structure") is kept
|
||||
# only in the server-side log, not echoed to the client.
|
||||
assert "check server logs" in error["details"]
|
||||
assert "Invalid config structure" not in error["details"]
|
||||
finally:
|
||||
# Restore original function
|
||||
explore_module.map_config_to_form_data = original_func
|
||||
@@ -774,11 +819,14 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.data["permalink_key"] == "extracted_permalink_xyz"
|
||||
assert result.data["form_data_key"] is None
|
||||
assert "extracted_permalink_xyz" in result.data["url"]
|
||||
assert result.data["url"] == (
|
||||
assert result.structured_content["error"] is None
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.structured_content["permalink_key"] == "extracted_permalink_xyz"
|
||||
)
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert "extracted_permalink_xyz" in result.structured_content["url"]
|
||||
assert result.structured_content["url"] == (
|
||||
"http://localhost:9001/explore/p/extracted_permalink_xyz/"
|
||||
)
|
||||
|
||||
@@ -803,13 +851,20 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert "form_data" in result.data
|
||||
assert isinstance(result.data["form_data"], dict)
|
||||
assert result.data["form_data"].get("viz_type") == "echarts_timeseries_line"
|
||||
assert result.data["form_data"].get("x_axis") == "date"
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert "form_data" in result.structured_content
|
||||
assert isinstance(result.structured_content["form_data"], dict)
|
||||
assert (
|
||||
result.structured_content["form_data"].get("viz_type")
|
||||
== "echarts_timeseries_line"
|
||||
)
|
||||
assert result.structured_content["form_data"].get("x_axis") == "date"
|
||||
# Verify datasource field format: "{dataset_id}__table"
|
||||
assert result.data["form_data"].get("datasource") == "1__table"
|
||||
assert (
|
||||
result.structured_content["form_data"].get("datasource") == "1__table"
|
||||
)
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
@@ -829,20 +884,26 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["url"] == ""
|
||||
assert result.data["form_data"] == {}
|
||||
assert result.data["form_data_key"] is None
|
||||
assert result.data["permalink_key"] is None
|
||||
assert result.data["chart_type_label"] is None
|
||||
assert "Dataset not found: 99999" in result.data["error"]
|
||||
assert "list_datasets" in result.data["error"]
|
||||
assert result.structured_content["url"] == ""
|
||||
assert result.structured_content["form_data"] == {}
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert result.structured_content["permalink_key"] is None
|
||||
assert result.structured_content["chart_type_label"] is None
|
||||
assert result.structured_content["success"] is False
|
||||
error = result.structured_content["error"]
|
||||
assert error["error_type"] == "dataset_not_found"
|
||||
assert "Dataset not found: 99999" in error["message"]
|
||||
assert "list_datasets" in error["details"]
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_explore_link_without_config(
|
||||
self, mock_find_dataset, mcp_server
|
||||
):
|
||||
"""Omitting config returns a default dataset explore URL."""
|
||||
"""Omitting config returns a default dataset explore URL through
|
||||
the same typed ``GenerateExploreLinkResponse`` shape as every
|
||||
other code path. ``success=True`` and ``error=None`` so callers
|
||||
cannot mistake a no-config response for a failure."""
|
||||
mock_find_dataset.return_value = _mock_dataset(id=42)
|
||||
|
||||
request = GenerateExploreLinkRequest(dataset_id="42")
|
||||
@@ -852,23 +913,26 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/?datasource_type=table"
|
||||
"&datasource_id=42"
|
||||
)
|
||||
assert result.data["form_data"] == {}
|
||||
assert result.data["form_data_key"] is None
|
||||
assert result.data["permalink_key"] is None
|
||||
assert result.data["chart_type_label"] is None
|
||||
assert result.structured_content["form_data"] == {}
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert result.structured_content["permalink_key"] is None
|
||||
assert result.structured_content["chart_type_label"] is None
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_explore_link_without_config_missing_dataset(
|
||||
self, mock_find_dataset, mcp_server
|
||||
):
|
||||
"""Omitting config still surfaces a dataset-not-found error."""
|
||||
"""Omitting config still surfaces a dataset-not-found error
|
||||
through the structured error object — not as a substring on a
|
||||
dict, which is the bug this test originally hid."""
|
||||
mock_find_dataset.return_value = None
|
||||
|
||||
request = GenerateExploreLinkRequest(dataset_id="99999")
|
||||
@@ -878,12 +942,15 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["url"] == ""
|
||||
assert result.data["form_data"] == {}
|
||||
assert result.data["form_data_key"] is None
|
||||
assert result.data["permalink_key"] is None
|
||||
assert result.data["chart_type_label"] is None
|
||||
assert "Dataset not found: 99999" in result.data["error"]
|
||||
assert result.structured_content["url"] == ""
|
||||
assert result.structured_content["form_data"] == {}
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert result.structured_content["permalink_key"] is None
|
||||
assert result.structured_content["chart_type_label"] is None
|
||||
assert result.structured_content["success"] is False
|
||||
error = result.structured_content["error"]
|
||||
assert error["error_type"] == "dataset_not_found"
|
||||
assert "Dataset not found: 99999" in error["message"]
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
@@ -905,12 +972,15 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["url"] == ""
|
||||
assert result.data["form_data"] == {}
|
||||
assert result.data["form_data_key"] is None
|
||||
assert result.data["permalink_key"] is None
|
||||
assert result.data["chart_type_label"] is None
|
||||
assert "Dataset not found" in result.data["error"]
|
||||
assert result.structured_content["url"] == ""
|
||||
assert result.structured_content["form_data"] == {}
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert result.structured_content["permalink_key"] is None
|
||||
assert result.structured_content["chart_type_label"] is None
|
||||
assert result.structured_content["success"] is False
|
||||
error = result.structured_content["error"]
|
||||
assert error["error_type"] == "dataset_not_found"
|
||||
assert "Dataset not found" in error["message"]
|
||||
|
||||
|
||||
class TestGenerateExploreLinkColumnNormalization:
|
||||
@@ -959,9 +1029,11 @@ class TestGenerateExploreLinkColumnNormalization:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
# x-axis should be normalized from 'orderdate' to 'OrderDate'
|
||||
assert result.data["form_data"]["x_axis"] == "OrderDate"
|
||||
assert result.structured_content["form_data"]["x_axis"] == "OrderDate"
|
||||
|
||||
@patch(
|
||||
"superset.mcp_service.chart.validation.dataset_validator.DatasetValidator._get_dataset_context"
|
||||
@@ -1004,8 +1076,10 @@ class TestGenerateExploreLinkColumnNormalization:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
form_data = result.data["form_data"]
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
form_data = result.structured_content["form_data"]
|
||||
# x-axis normalized
|
||||
assert form_data["x_axis"] == "OrderDate"
|
||||
# filter subject normalized to match x-axis
|
||||
@@ -1045,9 +1119,11 @@ class TestGenerateExploreLinkColumnNormalization:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
# original names should pass through unchanged
|
||||
assert result.data["form_data"]["x_axis"] == "orderdate"
|
||||
assert result.structured_content["form_data"]["x_axis"] == "orderdate"
|
||||
|
||||
|
||||
class TestGenerateExploreLinkValidation:
|
||||
@@ -1105,11 +1181,12 @@ class TestGenerateExploreLinkValidation:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["url"] == ""
|
||||
assert result.data["form_data_key"] is None
|
||||
assert result.data["permalink_key"] is None
|
||||
assert result.data["chart_type_label"] is None
|
||||
error = result.data["error"]
|
||||
assert result.structured_content["url"] == ""
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert result.structured_content["permalink_key"] is None
|
||||
assert result.structured_content["chart_type_label"] is None
|
||||
assert result.structured_content["success"] is False
|
||||
error = result.structured_content["error"]
|
||||
assert isinstance(error, dict)
|
||||
assert error["error_code"] == "CHART_VALIDATION_FAILED"
|
||||
assert "sum_boys" in error["suggestions"]
|
||||
@@ -1141,8 +1218,12 @@ class TestGenerateExploreLinkValidation:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["url"] == ""
|
||||
assert result.data["chart_type_label"] is None
|
||||
# Surface as "not found" rather than leaking that the dataset exists.
|
||||
assert "Dataset not found" in result.data["error"]
|
||||
assert result.structured_content["url"] == ""
|
||||
assert result.structured_content["chart_type_label"] is None
|
||||
assert result.structured_content["success"] is False
|
||||
error = result.structured_content["error"]
|
||||
# error_type lets programmatic callers distinguish, while the
|
||||
# user-facing message still avoids leaking dataset existence.
|
||||
assert error["error_type"] == "permission_denied"
|
||||
assert "Dataset not found" in error["message"]
|
||||
mock_create_permalink.assert_not_called()
|
||||
|
||||
@@ -83,6 +83,31 @@ async def test_algorithm_mismatch(hs256_verifier):
|
||||
assert "HS256" not in reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unpinned_algorithm_is_rejected(
|
||||
hs256_verifier: DetailedJWTVerifier,
|
||||
) -> None:
|
||||
"""A verifier with no pinned algorithm must reject signed tokens.
|
||||
|
||||
The upstream JWTVerifier currently always defaults the algorithm to RS256,
|
||||
so this state is not reachable through normal construction. This asserts the
|
||||
fail-closed guard so the verifier does not silently rely on that upstream
|
||||
default: if the pinned algorithm is ever absent, tokens are rejected rather
|
||||
than validated against an unconstrained algorithm family.
|
||||
"""
|
||||
# Simulate an unpinned verifier (e.g. a future upstream default change).
|
||||
hs256_verifier.algorithm = None
|
||||
token = _make_token(
|
||||
{"alg": "HS256", "typ": "JWT"},
|
||||
{"sub": "user1", "iss": "test-issuer", "aud": "test-audience"},
|
||||
)
|
||||
|
||||
result = await hs256_verifier.load_access_token(token)
|
||||
|
||||
assert result is None
|
||||
assert _jwt_failure_reason.get() == "No signing algorithm pinned"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_token_header(hs256_verifier):
|
||||
"""Token with invalid header should report malformed header."""
|
||||
@@ -407,6 +432,58 @@ async def test_token_without_expiration_rejected(hs256_verifier):
|
||||
assert "user1" not in reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_finite_expiration_rejected(hs256_verifier):
|
||||
"""An infinite exp must be rejected cleanly, not crash on int() overflow.
|
||||
|
||||
A JSON ``1e309`` decodes to ``float('inf')``, which passes the
|
||||
``exp < time.time()`` expiry check and would later raise ``OverflowError``
|
||||
on ``int(exp)`` — surfacing as a 500 instead of a 401. The finite-number
|
||||
guard rejects it with a precise reason before that can happen.
|
||||
"""
|
||||
token = _make_token(
|
||||
{"alg": "HS256", "typ": "JWT"},
|
||||
{"sub": "user1", "iss": "test-issuer", "aud": "test-audience"},
|
||||
)
|
||||
claims = {
|
||||
"sub": "user1",
|
||||
"iss": "test-issuer",
|
||||
"aud": "test-audience",
|
||||
"exp": float("inf"),
|
||||
}
|
||||
|
||||
with patch.object(hs256_verifier.jwt, "decode", return_value=claims):
|
||||
result = await hs256_verifier.load_access_token(token)
|
||||
|
||||
assert result is None
|
||||
assert _jwt_failure_reason.get() == "Token has invalid expiration"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_numeric_expiration_rejected(hs256_verifier):
|
||||
"""A non-numeric exp must be rejected with the invalid-expiration reason.
|
||||
|
||||
Without the finite-number guard, ``exp < time.time()`` would raise
|
||||
``TypeError`` and degrade to the generic "Token validation failed" reason.
|
||||
"""
|
||||
token = _make_token(
|
||||
{"alg": "HS256", "typ": "JWT"},
|
||||
{"sub": "user1", "iss": "test-issuer", "aud": "test-audience"},
|
||||
)
|
||||
claims = {
|
||||
"sub": "user1",
|
||||
"iss": "test-issuer",
|
||||
"aud": "test-audience",
|
||||
"exp": "2026-01-01",
|
||||
}
|
||||
|
||||
with patch.object(hs256_verifier.jwt, "decode", return_value=claims):
|
||||
result = await hs256_verifier.load_access_token(token)
|
||||
|
||||
assert result is None
|
||||
assert _jwt_failure_reason.get() == "Token has invalid expiration"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decode_error(hs256_verifier):
|
||||
"""Token that fails to decode should report decode failure."""
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.mcp_service.app import get_default_instructions, init_fastmcp_server
|
||||
|
||||
|
||||
@@ -383,6 +385,7 @@ def test_create_default_mcp_auth_factory_jwt_with_keys():
|
||||
"MCP_AUTH_ENABLED": True,
|
||||
"MCP_API_KEY_ENABLED": False,
|
||||
"FAB_API_KEY_ENABLED": False,
|
||||
"MCP_JWT_AUDIENCE": "superset-mcp",
|
||||
"MCP_JWT_SECRET": "shhh",
|
||||
}.get(key, default)
|
||||
|
||||
@@ -405,6 +408,7 @@ def test_create_default_mcp_auth_factory_jwt_enabled_without_keys_returns_none()
|
||||
"MCP_AUTH_ENABLED": True,
|
||||
"MCP_API_KEY_ENABLED": False,
|
||||
"FAB_API_KEY_ENABLED": False,
|
||||
"MCP_JWT_AUDIENCE": "superset-mcp",
|
||||
}.get(key, default)
|
||||
|
||||
with patch("superset.mcp_service.mcp_config.logger") as mock_logger:
|
||||
@@ -423,6 +427,7 @@ def test_create_default_mcp_auth_factory_jwt_build_failure_returns_none():
|
||||
"MCP_AUTH_ENABLED": True,
|
||||
"MCP_API_KEY_ENABLED": False,
|
||||
"FAB_API_KEY_ENABLED": False,
|
||||
"MCP_JWT_AUDIENCE": "superset-mcp",
|
||||
"MCP_JWT_SECRET": "shhh",
|
||||
}.get(key, default)
|
||||
|
||||
@@ -437,3 +442,45 @@ def test_create_default_mcp_auth_factory_jwt_build_failure_returns_none():
|
||||
|
||||
assert result is None
|
||||
mock_logger.error.assert_called_once()
|
||||
|
||||
|
||||
def test_create_default_mcp_auth_factory_requires_audience_when_jwt_enabled():
|
||||
"""MCP_AUTH_ENABLED=True without MCP_JWT_AUDIENCE fails closed.
|
||||
|
||||
A missing audience must raise MCPAuthConfigError (rather than returning a
|
||||
permissive verifier) so the bootstrap refuses to start the service instead
|
||||
of accepting same-issuer tokens minted for other services.
|
||||
"""
|
||||
from superset.mcp_service.mcp_config import (
|
||||
create_default_mcp_auth_factory,
|
||||
MCPAuthConfigError,
|
||||
)
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.config.get.side_effect = lambda key, default=None: {
|
||||
"MCP_AUTH_ENABLED": True,
|
||||
"MCP_API_KEY_ENABLED": False,
|
||||
"FAB_API_KEY_ENABLED": False,
|
||||
"MCP_JWT_SECRET": "shhh",
|
||||
}.get(key, default)
|
||||
|
||||
with pytest.raises(MCPAuthConfigError):
|
||||
create_default_mcp_auth_factory(mock_app)
|
||||
|
||||
|
||||
def test_create_default_mcp_auth_factory_audience_not_required_for_api_key_only():
|
||||
"""API-key-only auth (JWT disabled) does not require MCP_JWT_AUDIENCE."""
|
||||
from superset.mcp_service.composite_token_verifier import CompositeTokenVerifier
|
||||
from superset.mcp_service.mcp_config import create_default_mcp_auth_factory
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.config.get.side_effect = lambda key, default=None: {
|
||||
"MCP_AUTH_ENABLED": False,
|
||||
"MCP_API_KEY_ENABLED": True,
|
||||
"FAB_API_KEY_PREFIXES": ["sst_"],
|
||||
"MCP_REQUIRED_SCOPES": [],
|
||||
}.get(key, default)
|
||||
|
||||
result = create_default_mcp_auth_factory(mock_app)
|
||||
|
||||
assert isinstance(result, CompositeTokenVerifier)
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_create_event_store_returns_none_when_no_redis_url():
|
||||
"""EventStore returns None when no Redis URL configured (single-pod mode)."""
|
||||
@@ -181,3 +183,29 @@ def test_create_auth_provider_uses_default_factory_for_mcp_api_key_only() -> Non
|
||||
|
||||
assert result is auth_provider
|
||||
create_default_mcp_auth_factory.assert_called_once_with(flask_app)
|
||||
|
||||
|
||||
def test_create_auth_provider_propagates_auth_config_error() -> None:
|
||||
"""A fatal auth config error must propagate, not fall through to no auth.
|
||||
|
||||
The default factory raises MCPAuthConfigError for an unusable auth
|
||||
configuration. _create_auth_provider must re-raise it so the service fails
|
||||
to start instead of silently returning None (which would run unauthenticated).
|
||||
"""
|
||||
from superset.mcp_service.mcp_config import MCPAuthConfigError
|
||||
from superset.mcp_service.server import _create_auth_provider
|
||||
|
||||
flask_app = MagicMock()
|
||||
flask_app.config.get.side_effect = lambda key, default=None: {
|
||||
"MCP_AUTH_FACTORY": None,
|
||||
"MCP_AUTH_ENABLED": True,
|
||||
"MCP_API_KEY_ENABLED": False,
|
||||
"FAB_API_KEY_ENABLED": False,
|
||||
}.get(key, default)
|
||||
|
||||
with patch(
|
||||
"superset.mcp_service.mcp_config.create_default_mcp_auth_factory",
|
||||
side_effect=MCPAuthConfigError("MCP_JWT_AUDIENCE must be set"),
|
||||
):
|
||||
with pytest.raises(MCPAuthConfigError):
|
||||
_create_auth_provider(flask_app)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
# under the License.
|
||||
from datetime import datetime
|
||||
from importlib.util import find_spec
|
||||
from unittest.mock import patch
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
@@ -186,6 +187,43 @@ def test_prophet_incorrect_time_grain():
|
||||
)
|
||||
|
||||
|
||||
def test_prophet_insufficient_data():
|
||||
single_row_df = pd.DataFrame(
|
||||
{
|
||||
DTTM_ALIAS: [datetime(2022, 1, 1)],
|
||||
"sales": [100.0],
|
||||
}
|
||||
)
|
||||
with pytest.raises(InvalidPostProcessingError, match="at least 2 data points"):
|
||||
prophet(
|
||||
df=single_row_df,
|
||||
time_grain="P1D",
|
||||
periods=3,
|
||||
confidence_interval=0.9,
|
||||
)
|
||||
|
||||
|
||||
def test_prophet_fit_error():
|
||||
if find_spec("prophet") is None:
|
||||
pytest.skip("prophet not installed")
|
||||
|
||||
with patch(
|
||||
"superset.utils.pandas_postprocessing.prophet._prophet_fit_and_predict"
|
||||
) as mock_fit:
|
||||
mock_fit.side_effect = InvalidPostProcessingError(
|
||||
"Unable to generate forecast: Dataframe has fewer than 2 non-NaN rows."
|
||||
)
|
||||
with pytest.raises(
|
||||
InvalidPostProcessingError, match="Unable to generate forecast"
|
||||
):
|
||||
prophet(
|
||||
df=prophet_df,
|
||||
time_grain="P1D",
|
||||
periods=3,
|
||||
confidence_interval=0.9,
|
||||
)
|
||||
|
||||
|
||||
def test_prophet_uncertainty_lower_bound_can_be_negative_for_negative_series():
|
||||
"""
|
||||
Regression for #21734: when the input series contains negative values,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user