Compare commits

...
Author SHA1 Message Date
Mehmet Salih Yavuz f7318a6e7c fix(reports): apply animation wait only after spinners clear
Removes the early unconditional animation wait so the configured
SCREENSHOT_SELENIUM_ANIMATION_WAIT is applied once, after charts load,
rather than doubling it.
2026-06-30 11:26:06 +03:00
Mehmet Salih Yavuz 95f7589306 fix(reports): wait for ECharts animation after spinners clear in non-tiled screenshots 2026-06-25 19:35:38 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> fe2424ec14 chore(deps): bump mapbox-gl from 3.24.1 to 3.25.0 in /superset-frontend (#41409)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 02:09:48 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b4f43bd7e0 chore(deps): bump baseline-browser-mapping from 2.10.37 to 2.10.38 in /docs (#41405)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 02:09:45 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 2b25345ed9 chore(deps-dev): bump baseline-browser-mapping from 2.10.37 to 2.10.38 in /superset-frontend (#41413)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 02:09:41 -07:00
e0f3f93cd4 fix(mcp): require MCP_JWT_AUDIENCE when MCP JWT auth is enabled (#41292)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:53:36 -07:00
0667ba6097 chore(deps): bump dompurify and http-proxy-middleware (security) (#41289)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:56 -07:00
81f7e42f4e fix(rls): preserve tables/roles on partial RLS rule updates (#41294)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:47 -07:00
Evan RusackasandClaude Code 0fd244b5c6 fix(security): reject unknown fields on guest-token RLS rules (#41217)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-24 16:16:43 -07:00
1f16d10cbf chore(deps): bump pyjwt to 2.13.0 (CVE-2026-48526) (#41288)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:40 -07:00
Evan RusackasandClaude Opus 4.8 4f4663418f fix(tests): stabilize update_chart MCP test failing on previous-Python CI leg (#41310)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:16:14 -07:00
4519a5c52d fix(safe-markdown): do not mutate the shared sanitization schema (#41298)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:06 -07:00
da9fbadaf6 fix(logout): purge the namespaced Cache API store on logout (#41303)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:50 -07:00
f40abbbefd fix(mcp): fail closed when the JWT verifier has no pinned algorithm (#41296)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:42 -07:00
6166af3c3c fix(mcp): reject non-finite JWT exp instead of 500ing on int() overflow (#41394)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:29 -07:00
076d8c1508 docs(security): add a secrets register and rotation schedule (#41308)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:17 -07:00
Elizabeth Thompson 518cadd907 fix(mcp_service): reduce deprecated authlib.jose.errors imports (#41248) 2026-06-24 15:01:58 -07:00
JUST.in DO IT b955c90de4 fix(sqllab): Invalid multi sorting state in table header (#40680) 2026-06-25 06:43:02 +09:00
Evan RusackasDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Claude Fable 5
7363774869 fix(theming): deep-merge partial THEME_DEFAULT overrides with built-in defaults (#41347)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 13:27:32 -07:00
6f12d17313 fix(charts): show user-friendly error for HTTP 413 payload too large (#37131)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-24 11:21:59 -07:00
09c7ba14df fix(export): sanitize control characters in titles to prevent export failures (#39294)
Co-authored-by: Abhyuday Tomar <abhyuday.tomar@exotel.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:03:46 -07:00
Elizabeth ThompsonandClaude Sonnet 4.6 3ec4bd23c4 fix(deps): restore np.nan in offset_metrics_df empty branch (#41267)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 10:49:38 -07:00
yousophandClaude Sonnet 4.6 f6ce105450 fix(pandas-postprocessing): handle prophet errors and validate minimum data points for forecast (#41180)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 10:44:23 -07:00
7bb4e82a82 fix(dashboard): Remove 308 redirect when creating new dashboards (#41343)
Co-authored-by: ericsong <eric.song@example.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:31:31 -07:00
Kamil Gabryjelski 2d78a8733c fix(plugin-chart-ag-grid-table): show correct percent-metric totals in summary row (#41247)
Signed-off-by: Kamil Gabryjelski <kamil.gabryjelski@gmail.com>
2026-06-24 19:21:00 +02:00
Evan RusackasandClaude Code 3261d10270 chore(frontend): enforce TypeScript-only source files (#41385)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-24 05:54:37 -07:00
a57b5f6078 fix(deckgl): show dashboard filter badges for multi-layer charts (#40003)
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:14:25 -07:00
d1b523b97f docs: fix placeholder text in @superset-ui/core README (#40002)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:07:24 -07:00
91188a0302 fix(config): Wire LOGO_TARGET_PATH and document custom spinner usage (#36951)
Co-authored-by: Shashwati <shashwatibhattacaharya21.2@gmail.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-06-24 01:56:15 -07:00
MUHAMMED SINAN D ac234d0fb2 fix(dashboard): prevent x-axis clipping when toggling chart description (#38307) 2026-06-24 01:54:43 -07:00
8eb753eab2 fix(dashboard): keep native filter dropdown from covering input (#40032)
Co-authored-by: feehgrossi <felipe.leite@sptech.school>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:53:44 -07:00
779fa13679 fix(security): prevent duplicate items in permissions dropdown on scroll (#39292)
Co-authored-by: Abhyuday Tomar <abhyuday.tomar@exotel.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:53:27 -07:00
caf81e71d2 feat(mcp): add typed Pydantic response schemas to generate_explore_link tool (#39900)
Co-authored-by: gkneighb <26003+gkneighb@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 01:53:08 -07:00
1b8c6d109d feat: added deterministic field generation to dashboard export (#36339)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 01:41:44 -07:00
Viktor Högberg eb60e5477b fix(radar): correct legend margin control in the radar chart (#39414) 2026-06-24 01:41:24 -07:00
7b9bcdd951 fix(bigquery): preserve catalog in partition metadata lookup (#40200)
Co-authored-by: Puneet Dixit <rvit23bcs086.rvitm@rvei.edu.in>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-24 01:41:06 -07:00
ruhz3 d9d395bde1 fix(helm): remove unused SQLALCHEMY_TRACK_MODIFICATIONS setting (#37259) 2026-06-24 01:28:30 -07:00
Jay MasiwalandJoe Li 584d41759b refactor: migrate test files from nested describe blocks and remove stale lint ignores (#39202)
Co-authored-by: Joe Li <joe@preset.io>
2026-06-24 01:19:15 -07:00
abdullah revehaandAbdullah Sahin 8f22b71898 feat(chart): enable cross-filter on x-axis labels for bar, line, area and scatter charts (#41111)
Co-authored-by: Abdullah Sahin <you@example.comclear>
2026-06-24 01:17:29 -07:00
omkarhall 1ea3584dcb fix(chart): added Big Number chart support for MAX metric with VARCHAR column (#41182) 2026-06-24 01:11:13 -07:00
104 changed files with 4031 additions and 1005 deletions
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.17.2](https://img.shields.io/badge/Version-0.17.2-informational?style=flat-square)
![Version: 0.17.3](https://img.shields.io/badge/Version-0.17.3-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application
-2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+16 -156
View File
@@ -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"
+2 -2
View File
@@ -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.
[![Version](https://img.shields.io/npm/v/@superset-ui/core.svg?style=flat)](https://www.npmjs.com/package/@superset-ui/core)
[![Libraries.io](https://img.shields.io/librariesio/release/npm/%40superset-ui%2Fcore?style=flat)](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
```
@@ -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();
@@ -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', () => {
@@ -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' }];
@@ -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: {
@@ -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',
@@ -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);
});
});
@@ -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', () => {
+61 -14
View File
@@ -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]);
@@ -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,
@@ -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',
});
});
+15 -9
View File
@@ -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 });
},
});
}
+121 -127
View File
@@ -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);
});
+57 -1
View File
@@ -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 -1
View File
@@ -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+0000U+001F, U+007FU+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, '');
}
+2 -11
View File
@@ -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,
+6 -5
View File
@@ -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
+22 -11
View File
@@ -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
View File
@@ -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)
+4 -1
View File
@@ -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
+92
View File
@@ -0,0 +1,92 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
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,
)
+48 -19
View File
@@ -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)
+26 -2
View File
@@ -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:
+7
View File
@@ -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")
+1 -1
View File
@@ -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())
}
)
+1
View File
@@ -184,6 +184,7 @@ class RLSPutSchema(Schema):
metadata={"description": "tables_description"},
required=False,
allow_none=False,
validate=Length(1),
)
roles = fields.List(
fields.Integer(),
+36 -3
View File
@@ -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?
+13
View File
@@ -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+0000U+001F) and C1 (U+007FU+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",
+17 -4
View File
@@ -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
View File
@@ -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
+56
View File
@@ -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,
},
+74 -1
View File
@@ -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()
+37
View File
@@ -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