mirror of
https://github.com/apache/superset.git
synced 2026-08-25 01:21:18 +00:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93ddafa6b5 | ||
|
|
dfd057b920 | ||
|
|
52571a5b8b | ||
|
|
ca3d7670b7 | ||
|
|
94855e9626 | ||
|
|
7dbf71a379 | ||
|
|
24b95f9ca7 | ||
|
|
c635d0754f | ||
|
|
31f06c0ee6 | ||
|
|
27ec80c07b | ||
|
|
a8a8b51afb | ||
|
|
6ab21b381a | ||
|
|
b0962ba5ed | ||
|
|
bf5f3a9e6b | ||
|
|
c980b3a361 | ||
|
|
dc436c76f7 | ||
|
|
6eced8e919 | ||
|
|
1bde62f997 | ||
|
|
f1f6347885 | ||
|
|
8591f52ced | ||
|
|
ceb75b9350 | ||
|
|
db539288ac | ||
|
|
a392e8b102 | ||
|
|
e450acf1c7 | ||
|
|
2c02965f2b | ||
|
|
f3142e7b15 | ||
|
|
649c062825 | ||
|
|
3f10d8b1cc | ||
|
|
15b98aa6da | ||
|
|
7f1b41444c | ||
|
|
8a32324184 |
@@ -105,6 +105,7 @@ jobs:
|
||||
tool: customSmallerIsBetter
|
||||
output-file-path: bundle-size-summary.json
|
||||
external-data-json-path: bundle-size-history.json
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fail-on-alert: false
|
||||
summary-always: true
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ the old counter to use the outcome-specific replacements.
|
||||
|
||||
- [42930](https://github.com/apache/superset/pull/42930): Dataset import data-URI fetches no longer honor an HTTP(S) proxy when `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS` is `False` (the default): the connection is now made directly to the destination so the peer-address check validates the real target instead of a proxy's. Deployments that require an egress proxy to reach legitimate external data URLs for dataset import should set `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS = True` or otherwise ensure those URLs resolve without one.
|
||||
- [42935](https://github.com/apache/superset/pull/42935): The MCP service now refuses to start (`MCPAuthConfigError`) when `MCP_JWT_ISSUER` trusts more than one issuer and no `MCP_USER_RESOLVER` is configured, instead of only logging a warning. This was already a documented misconfiguration (the default resolver isn't issuer-scoped, so distinct trusted issuers minting the same username/email would resolve to the same Superset user); deployments trusting multiple issuers must configure an `MCP_USER_RESOLVER` that derives its identity from the token's `iss` claim before upgrading. Single-issuer deployments are unaffected.
|
||||
- [43388](https://github.com/apache/superset/pull/43388): The MCP service now refuses to start (`MCPAuthConfigError`) if `MCP_DEV_USERNAME` and `MCP_AUTH_ENABLED = True` are both set, and separately if `MCP_AUTH_ENABLED = True` but no usable JWT key material is configured (RSA key/JWKS, or an explicit `MCP_JWT_SECRET` for HMAC) — both previously started with authentication silently weaker than configured. Deployments combining a dev-mode username with JWT auth enabled, or enabling JWT auth without key material, must pick one before upgrading: unset `MCP_DEV_USERNAME` for a real auth deployment, or unset `MCP_AUTH_ENABLED` (or configure the key material) for a dev-mode one. Response caching (`MCP_CACHE_CONFIG["enabled"] = True`) now also excludes every tool with a side effect by default, not only a partial list, so a previously-cached mutating tool call is no longer served from cache; no config change is needed to pick this up.
|
||||
- [42393](https://github.com/apache/superset/pull/42393): Exported dataset YAML now carries a `uuid` for each metric and column so that custom folder assignments (which reference metrics/columns by UUID) survive an import into another workspace. This affects any export bundle that contains datasets, not just a dataset export: chart, dashboard, database and full-asset exports all embed the same dataset YAML, so a dashboard exported from this release also fails to import into an older one even though no dataset was exported directly. As with `folders` and `currency_code_column`, the affected `datasets/` files fail schema validation (`Unknown field: uuid`) when imported into Superset releases that predate this change; regenerate or hand-edit exports for older targets in mixed-version fleets.
|
||||
- [42300](https://github.com/apache/superset/pull/42300): Timeseries charts (line/area/bar) with a Y-axis bound in effect — either an explicit `yAxisBounds` or one derived from `truncateYAxis` — now clamp out-of-range data points to that bound instead of letting ECharts drop the point (and the line segments around it) entirely. Any existing chart with a configured Y-axis bound and data outside it will look different after upgrading: a gap becomes a point pinned to the boundary. The clamp also rewrites the value ECharts reads for that point's tooltip and data label, so the displayed value is the bound rather than the true observation.
|
||||
- [42087](https://github.com/apache/superset/pull/42087): Stored calculated-column and metric expressions are validated when a query is built, under the same sub-query policy already applied to adhoc expressions. Previously only the dataset update path checked them on save, so expressions written by v1 import, by dataset duplication, or before that check existed were never validated. Since `ALLOW_ADHOC_SUBQUERY` defaults to `False` (see [19242](https://github.com/apache/superset/pull/19242)), a dataset whose stored expression contains a sub-query works before upgrading and afterwards fails at chart render with `Custom SQL fields cannot contain sub-queries.` There is no migration step, and the error does not name the offending dataset column, so audit stored expressions before upgrading: either rewrite them without the sub-query, or set `ALLOW_ADHOC_SUBQUERY = True` to keep the previous behaviour for both stored and adhoc expressions.
|
||||
|
||||
@@ -782,13 +782,18 @@ Enable response caching for read-heavy workloads (dashboards/datasets that don't
|
||||
```python
|
||||
MCP_CACHE_CONFIG = {
|
||||
"enabled": True,
|
||||
# Cache keys don't include the requesting principal and hits are served
|
||||
# ahead of auth/RBAC, so a shared cache can return one caller's response
|
||||
# to another. Required for caching to actually start -- only appropriate
|
||||
# when every request is guaranteed to come from the same principal.
|
||||
"dangerously_share_cache_across_principals": True,
|
||||
"CACHE_KEY_PREFIX": "mcp_cache_",
|
||||
"call_tool_ttl": 3600,
|
||||
}
|
||||
MCP_STORE_CONFIG = {"enabled": True, "CACHE_REDIS_URL": "redis://redis:6379/0"}
|
||||
```
|
||||
|
||||
Mutating tools (`generate_chart`, `update_chart`, `execute_sql`, `generate_dashboard`) are always excluded from caching regardless of this setting.
|
||||
Every tool with a side effect (create/update/delete/execute) is always excluded from caching regardless of this setting -- see the `excluded_tools` default in `superset/mcp_service/mcp_config.py` for the current list.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -60,9 +60,9 @@
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^10.5.8",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.47",
|
||||
"@swc/core": "^1.16.0",
|
||||
"antd": "^6.6.0",
|
||||
"baseline-browser-mapping": "^2.11.13",
|
||||
"baseline-browser-mapping": "^2.11.15",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
"docusaurus-plugin-openapi-docs": "^5.2.0",
|
||||
"docusaurus-theme-openapi-docs": "^5.2.0",
|
||||
|
||||
+73
-73
@@ -4855,86 +4855,86 @@
|
||||
dependencies:
|
||||
apg-lite "^1.0.4"
|
||||
|
||||
"@swc/core-darwin-arm64@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.47.tgz#345ce6a1bf4033da189c2e3eff1244190195d15b"
|
||||
integrity sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==
|
||||
"@swc/core-darwin-arm64@1.16.0":
|
||||
version "1.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.0.tgz#8c5a2af031c62ebcb6354aa6975bfb7eac895223"
|
||||
integrity sha512-SJQPl+xG/zB8bNjC/gTg3WOmOvz7EzlQD+VShfCKFYPNr2qvb+vATUY11vYEjnMWCn6wV8H8eAtjQrVflYyX5A==
|
||||
|
||||
"@swc/core-darwin-x64@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.47.tgz#f3debf50b5c1602bf392acb412bd33fd6d7e4f98"
|
||||
integrity sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==
|
||||
"@swc/core-darwin-x64@1.16.0":
|
||||
version "1.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.16.0.tgz#0d2496c0429d7e8bc45b50348adf9d105bb56793"
|
||||
integrity sha512-ql2JVch8V5t1i+HxiiuD4oVDI1dOku4/e3QiCkplONrm3SLitqNAP+nztHN51fSG2IgGuOwpAi3hgA+ukT5yQg==
|
||||
|
||||
"@swc/core-linux-arm-gnueabihf@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.47.tgz#14a247a12c6d3de1ee63fa4fdbf5a4302936b5d6"
|
||||
integrity sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==
|
||||
"@swc/core-linux-arm-gnueabihf@1.16.0":
|
||||
version "1.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.0.tgz#5f02a85842a04cd21f2ab9e8e67dc4b16f7024a4"
|
||||
integrity sha512-PcdDBaRbe39y37h1rXVkhNy7mEU7f8b34KD761C68R23EsfMsj5oDPVddRzGdSRAvwwSfH0WSNEHgYmc/AJipg==
|
||||
|
||||
"@swc/core-linux-arm64-gnu@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.47.tgz#3b8d09c481ae51c7b72d98fb6ce98f7b90065a1a"
|
||||
integrity sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==
|
||||
"@swc/core-linux-arm64-gnu@1.16.0":
|
||||
version "1.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.0.tgz#6264498c88c51649511c6b4af532d330d3cf0631"
|
||||
integrity sha512-t21IUztHQ/COucy7Kk9eIlehmq08H/hYq7aRA6fZox3S5ddi6TxWPK6e5S/+aTCf6+Od9qQ+LIpjHMiTy737vA==
|
||||
|
||||
"@swc/core-linux-arm64-musl@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.47.tgz#7ff2baa16e67b29017fdf7c6b69e40de7920ce1a"
|
||||
integrity sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==
|
||||
"@swc/core-linux-arm64-musl@1.16.0":
|
||||
version "1.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.0.tgz#7a451eba69aa9a80799b9b8b9af46bf6f49803bd"
|
||||
integrity sha512-d9+iajbMB87b0umgbP+Gy3yBDSDgty4Q6H5pZ8fgTb/dOoKIwwynP4L4kvWCOFg2i49kxmAAUs1uJZh9s0E+RQ==
|
||||
|
||||
"@swc/core-linux-ppc64-gnu@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.47.tgz#a3841982fe2eb2d889648c8e212b6d821db316d6"
|
||||
integrity sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==
|
||||
"@swc/core-linux-ppc64-gnu@1.16.0":
|
||||
version "1.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.0.tgz#99a7ba46a56190a52c646506e940dffe554c5d10"
|
||||
integrity sha512-QRpeKGOg+B0qmo3BFU+6rL/gpoKYYJ7OFSMf5DNMafohYZ/iq2qvAH9Gcrf8NxROj3iooKOVewJ+YgahH1nSLw==
|
||||
|
||||
"@swc/core-linux-s390x-gnu@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.47.tgz#edbfd705d6285f7dce48915871478bc9603904c3"
|
||||
integrity sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==
|
||||
"@swc/core-linux-s390x-gnu@1.16.0":
|
||||
version "1.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.0.tgz#61473e056d1dd0d4690352a875c14f41bdd9f60a"
|
||||
integrity sha512-q+Vr/hmHCcRXT/WFzOJC+T6GGEEtq2iaTtmyLfxO7yzu4ckgcqSNkg9m181wfNhuMwfNBoBhOfwQCnLsGZ5F4g==
|
||||
|
||||
"@swc/core-linux-x64-gnu@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.47.tgz#e7f61a7771d6a9b5b274521ba61809b3d7644325"
|
||||
integrity sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==
|
||||
"@swc/core-linux-x64-gnu@1.16.0":
|
||||
version "1.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.0.tgz#008fc149a9135bca92b1e1f63037e2612c4d0fb5"
|
||||
integrity sha512-DWVBc3QnpsSgKoq8N4rmZeZa5r/XrHdLkITsExN/tvTdqPtAPDPt+Ysy33OfgBlyN8lNe4xwsXWe6DXlRkJeRQ==
|
||||
|
||||
"@swc/core-linux-x64-musl@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.47.tgz#7c1ef8305444bcc7894de177fe225f2d8f3be609"
|
||||
integrity sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==
|
||||
"@swc/core-linux-x64-musl@1.16.0":
|
||||
version "1.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.0.tgz#4300ea0c63864dc3989ca0e956b4a5e4c666196c"
|
||||
integrity sha512-6XCgDSc1HPf/5dpjvABhKHICiBcsuZyW3hQMkn8sxel0TqprkJGp+H4iaBYIUTPixhrBub2hBPtfjcZLE6yL3w==
|
||||
|
||||
"@swc/core-win32-arm64-msvc@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.47.tgz#953856d26b28956d1a18ef10e5f221202b2cb8f1"
|
||||
integrity sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==
|
||||
"@swc/core-win32-arm64-msvc@1.16.0":
|
||||
version "1.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.0.tgz#1d4146b7c1aada2992692cdc72bb0b43a885136e"
|
||||
integrity sha512-T/+9VVCZJ3AKEth9IP3U9AJ2YscQq+7LUqRTvfR4a2q36+Ri22oOwUizpAKOqQ42vb2Y/kOa4TOcJOfHoDIT/w==
|
||||
|
||||
"@swc/core-win32-ia32-msvc@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.47.tgz#2743a5bccc49f252c23bad3135193640cbdcef3a"
|
||||
integrity sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==
|
||||
"@swc/core-win32-ia32-msvc@1.16.0":
|
||||
version "1.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.0.tgz#c5c2a60905ffa9e4647214bef75778f0c73ba0d4"
|
||||
integrity sha512-Pr1lsR/PMs8ndL0UWMrW8nLZ7H7sspIxBRDdjL8f+YJ/FJNASgzfunbVVXAqj0csgIJYHPZy+OW9smjFmk1Rcg==
|
||||
|
||||
"@swc/core-win32-x64-msvc@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.47.tgz#9674ad0c9187b7cbe5cc3080b31b960d3ee688b9"
|
||||
integrity sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==
|
||||
"@swc/core-win32-x64-msvc@1.16.0":
|
||||
version "1.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.0.tgz#67dd85a90437e6fa9951cce7842f6cac3ec3f60d"
|
||||
integrity sha512-ktdeYLgOQdaonvsj5tJijqgpb0wk7gfF80wCFVA0kucI1hhSUIyfcGbjo5+9sdqv38OhMnTdLoA6xbqgOgPQjw==
|
||||
|
||||
"@swc/core@^1.15.40", "@swc/core@^1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.47.tgz#6226e842160e247eb79a9aeac1095ebddb56639f"
|
||||
integrity sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==
|
||||
"@swc/core@^1.15.40", "@swc/core@^1.16.0":
|
||||
version "1.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.16.0.tgz#79cd13789725d3e3ad0df605dc88d9e255d7ebfd"
|
||||
integrity sha512-zSdvEHxBg00WhUNtW/u58hhcdR33gjtMQvOBo8F7POWJDyjRCt/miKfhidT3hCc/118RUwNnlEAmxiihFMbK4Q==
|
||||
dependencies:
|
||||
"@swc/counter" "^0.1.3"
|
||||
"@swc/types" "^0.1.27"
|
||||
"@swc/types" "^0.1.28"
|
||||
optionalDependencies:
|
||||
"@swc/core-darwin-arm64" "1.15.47"
|
||||
"@swc/core-darwin-x64" "1.15.47"
|
||||
"@swc/core-linux-arm-gnueabihf" "1.15.47"
|
||||
"@swc/core-linux-arm64-gnu" "1.15.47"
|
||||
"@swc/core-linux-arm64-musl" "1.15.47"
|
||||
"@swc/core-linux-ppc64-gnu" "1.15.47"
|
||||
"@swc/core-linux-s390x-gnu" "1.15.47"
|
||||
"@swc/core-linux-x64-gnu" "1.15.47"
|
||||
"@swc/core-linux-x64-musl" "1.15.47"
|
||||
"@swc/core-win32-arm64-msvc" "1.15.47"
|
||||
"@swc/core-win32-ia32-msvc" "1.15.47"
|
||||
"@swc/core-win32-x64-msvc" "1.15.47"
|
||||
"@swc/core-darwin-arm64" "1.16.0"
|
||||
"@swc/core-darwin-x64" "1.16.0"
|
||||
"@swc/core-linux-arm-gnueabihf" "1.16.0"
|
||||
"@swc/core-linux-arm64-gnu" "1.16.0"
|
||||
"@swc/core-linux-arm64-musl" "1.16.0"
|
||||
"@swc/core-linux-ppc64-gnu" "1.16.0"
|
||||
"@swc/core-linux-s390x-gnu" "1.16.0"
|
||||
"@swc/core-linux-x64-gnu" "1.16.0"
|
||||
"@swc/core-linux-x64-musl" "1.16.0"
|
||||
"@swc/core-win32-arm64-msvc" "1.16.0"
|
||||
"@swc/core-win32-ia32-msvc" "1.16.0"
|
||||
"@swc/core-win32-x64-msvc" "1.16.0"
|
||||
|
||||
"@swc/counter@^0.1.3":
|
||||
version "0.1.3"
|
||||
@@ -5021,10 +5021,10 @@
|
||||
"@swc/html-win32-ia32-msvc" "1.15.43"
|
||||
"@swc/html-win32-x64-msvc" "1.15.43"
|
||||
|
||||
"@swc/types@^0.1.27":
|
||||
version "0.1.27"
|
||||
resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.27.tgz#12080b0c426dea450634f202d9a3c82ac396e793"
|
||||
integrity sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==
|
||||
"@swc/types@^0.1.28":
|
||||
version "0.1.28"
|
||||
resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.28.tgz#e3cd892383fba3b8904c40518bbe1265a50753f2"
|
||||
integrity sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==
|
||||
dependencies:
|
||||
"@swc/counter" "^0.1.3"
|
||||
|
||||
@@ -6522,10 +6522,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.13, baseline-browser-mapping@^2.9.19:
|
||||
version "2.11.13"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz#660073103c1bee93e54df55f117b7528adf6af19"
|
||||
integrity sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==
|
||||
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.15, baseline-browser-mapping@^2.9.19:
|
||||
version "2.11.15"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz#9c0cac93d7d304f3d61bb41088a102cd62e68676"
|
||||
integrity sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
|
||||
+8
-8
@@ -80,7 +80,7 @@ dependencies = [
|
||||
# marshmallow 4 compatibility: see superset/marshmallow_compatibility.py for a
|
||||
# Flask-AppBuilder workaround. Tracking issue:
|
||||
# https://github.com/apache/superset/issues/33162
|
||||
"marshmallow>=3.0, <5",
|
||||
"marshmallow>=4.3.1, <5",
|
||||
"marshmallow-union>=0.1.15.post1",
|
||||
"msgpack>=1.2.0, <1.3",
|
||||
"nh3>=0.3.5, <0.4",
|
||||
@@ -101,7 +101,7 @@ dependencies = [
|
||||
"python-dateutil",
|
||||
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
|
||||
"pygeohash",
|
||||
"pyarrow>=24.0.0, <26", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
|
||||
"pyarrow>=25.0.1, <26", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
|
||||
"pyyaml>=6.0.3, <7.0.0",
|
||||
"PyJWT>=2.4.0, <3.0",
|
||||
"redis>=5.0.0, <9.0",
|
||||
@@ -111,10 +111,10 @@ dependencies = [
|
||||
"sshtunnel>=0.4.0, <0.5",
|
||||
"simplejson>=4.1.1",
|
||||
"slack_sdk>=3.43.0, <4",
|
||||
"sqlalchemy>=2.0.0, <2.1",
|
||||
"sqlalchemy>=2.0.52, <2.1",
|
||||
"sqlalchemy-continuum>=1.6.0, <2.0.0",
|
||||
"sqlalchemy-utils>=0.42.1, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=30.16.0, <31", # 30.16.0 adds Trino inline UDF IF/CASE routine statement parsing
|
||||
"sqlglot>=30.17.0, <31", # 30.16.0 adds Trino inline UDF IF/CASE routine statement parsing
|
||||
# newer pandas needs 0.9+
|
||||
"tabulate>=0.10.0, <1.0",
|
||||
"typing-extensions>=4.16.0, <5",
|
||||
@@ -141,7 +141,7 @@ bigquery = [
|
||||
"sqlalchemy-bigquery>=1.17.2",
|
||||
"google-cloud-bigquery>=3.42.3",
|
||||
]
|
||||
clickhouse = ["clickhouse-connect>=1.6.0, <2.0"]
|
||||
clickhouse = ["clickhouse-connect>=1.7.1, <2.0"]
|
||||
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
|
||||
crate = ["sqlalchemy-cratedb>=0.43.1, <1"]
|
||||
# sqlalchemy-d1's only release (0.1.0, Nov 2025) pins sqlalchemy<2,>=1.4,
|
||||
@@ -197,7 +197,7 @@ fastmcp = [
|
||||
# landed (discussion #40273).
|
||||
firebird = ["sqlalchemy-firebird>=2.2.0"]
|
||||
firebolt = ["firebolt-sqlalchemy>=1.1.2, <2"]
|
||||
gevent = ["gevent>=26.7.0"]
|
||||
gevent = ["gevent>=26.8.0"]
|
||||
gsheets = ["shillelagh[gsheetsapi]>=1.4.5, <2"]
|
||||
hana = ["hdbcli==2.29.25", "sqlalchemy_hana==3.0.3"]
|
||||
hive = [
|
||||
@@ -232,7 +232,7 @@ playwright = ["playwright>=1.62.0, <2"]
|
||||
postgres = ["psycopg2-binary==2.9.12"]
|
||||
presto = ["pyhive[presto]>=0.6.5"]
|
||||
trino = ["trino>=0.338.0"]
|
||||
prophet = ["prophet>=1.3.0, <2"]
|
||||
prophet = ["prophet>=1.4.0, <2"]
|
||||
# sqlalchemy-redshift cuts hard from SQLAlchemy 1.4-only (0.8.x) to 2.0-only
|
||||
# (>=1.0.0) with no dual-compat release. Bumped now that Superset's own
|
||||
# SQLAlchemy 2.0 core bump has landed (discussion #40273).
|
||||
@@ -255,7 +255,7 @@ tdengine = [
|
||||
"taospy>=2.8.10",
|
||||
"taos-ws-py>=0.7.0"
|
||||
]
|
||||
teradata = ["teradatasql>=20.0.0.64"]
|
||||
teradata = ["teradatasql>=20.0.0.65"]
|
||||
thumbnails = [] # deprecated, will be removed in 7.0
|
||||
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
|
||||
netezza = ["nzalchemy>= 11.1.2, < 11.2"]
|
||||
|
||||
@@ -30,7 +30,7 @@ cryptography>=50.0.0,<51.0.0
|
||||
# Security: Snyk - XSS vulnerability in Mako templates
|
||||
mako>=1.4.1,<2.0.0
|
||||
# Security: CVE-2024-52338 (CRITICAL) - Deserialization of untrusted data in IPC/Parquet readers
|
||||
pyarrow>=24.0.0,<26.0.0
|
||||
pyarrow>=25.0.1,<26.0.0
|
||||
# Security: CVE-2026-27459 - pyopenssl certificate validation
|
||||
pyopenssl>=26.0.0,<27.0.0
|
||||
# Security: CVE-2026-25645 (MEDIUM) - Insecure Temporary File
|
||||
|
||||
@@ -222,7 +222,7 @@ markupsafe==3.0.2
|
||||
# mako
|
||||
# werkzeug
|
||||
# wtforms
|
||||
marshmallow==4.3.0
|
||||
marshmallow==4.3.1
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
@@ -287,7 +287,7 @@ prison==0.2.1
|
||||
# via flask-appbuilder
|
||||
prompt-toolkit==3.0.51
|
||||
# via click-repl
|
||||
pyarrow==25.0.0
|
||||
pyarrow==25.0.1
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
@@ -381,7 +381,7 @@ six==1.17.0
|
||||
# wtforms-json
|
||||
slack-sdk==3.43.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
sqlalchemy==2.0.51
|
||||
sqlalchemy==2.0.52
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# alembic
|
||||
@@ -399,7 +399,7 @@ sqlalchemy-utils==0.42.1
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
sqlglot==30.16.0
|
||||
sqlglot==30.17.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
|
||||
@@ -337,7 +337,7 @@ geopy==2.4.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
gevent==26.7.0
|
||||
gevent==26.8.0
|
||||
# via apache-superset
|
||||
google-api-core==2.33.0
|
||||
# via
|
||||
@@ -434,8 +434,6 @@ importlib-metadata==8.7.0
|
||||
# via
|
||||
# keyring
|
||||
# opentelemetry-api
|
||||
importlib-resources==6.5.2
|
||||
# via prophet
|
||||
iniconfig==2.0.0
|
||||
# via pytest
|
||||
isodate==0.7.2
|
||||
@@ -530,7 +528,7 @@ markupsafe==3.0.2
|
||||
# mako
|
||||
# werkzeug
|
||||
# wtforms
|
||||
marshmallow==4.3.0
|
||||
marshmallow==4.3.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -693,7 +691,7 @@ prompt-toolkit==3.0.51
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# click-repl
|
||||
prophet==1.3.0
|
||||
prophet==1.4.0
|
||||
# via apache-superset
|
||||
proto-plus==1.25.0
|
||||
# via google-api-core
|
||||
@@ -711,7 +709,7 @@ psycopg2-binary==2.9.12
|
||||
# via apache-superset
|
||||
py-key-value-aio==0.4.4
|
||||
# via fastmcp-slim
|
||||
pyarrow==25.0.0
|
||||
pyarrow==25.0.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -950,7 +948,7 @@ slack-sdk==3.43.0
|
||||
# apache-superset
|
||||
sniffio==1.3.1
|
||||
# via anyio
|
||||
sqlalchemy==2.0.51
|
||||
sqlalchemy==2.0.52
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
@@ -976,7 +974,7 @@ sqlalchemy-utils==0.42.1
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
sqlglot==30.16.0
|
||||
sqlglot==30.17.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -42,6 +42,7 @@ RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429})
|
||||
PATTERNS = {
|
||||
"python": [
|
||||
r"^\.github/workflows/.*python",
|
||||
r"^\.github/workflows/frontend-bundle-size-nightly\.yml$",
|
||||
r"^\.github/workflows/scheduled-docker-image-refresh\.yml$",
|
||||
r"^docker-compose-image-tag\.yml$",
|
||||
r"^tests/",
|
||||
|
||||
Generated
+14
-14
@@ -45,9 +45,9 @@
|
||||
"@luma.gl/shadertools": "~9.2.5",
|
||||
"@luma.gl/webgl": "~9.2.5",
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@rjsf/core": "^6.7.1",
|
||||
"@rjsf/core": "^6.8.0",
|
||||
"@rjsf/utils": "^6.6.2",
|
||||
"@rjsf/validator-ajv8": "^6.7.1",
|
||||
"@rjsf/validator-ajv8": "^6.8.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
|
||||
"@superset-ui/core": "file:./packages/superset-ui-core",
|
||||
@@ -10340,9 +10340,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rjsf/core": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.7.1.tgz",
|
||||
"integrity": "sha512-/CQfIGUzcXceBNRhEH3wsTvxcT8dMrjPLXhYSfcJUTftKxOCdisqi2wFwt7LOyIvFvzHUxag0r0xXs/MXS9jmA==",
|
||||
"version": "6.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.8.0.tgz",
|
||||
"integrity": "sha512-HZ2e/l/QNcz8PTslBXyGWkiycMe3LgIfwgXo0qO3DPgyFKj4G+obJTKBq/W+DaIUmz1HicvH5WtOdXWWqK+gbA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"lodash": "^4.18.1",
|
||||
@@ -10354,19 +10354,19 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rjsf/utils": "^6.7.1",
|
||||
"@rjsf/utils": "^6.8.0",
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/utils": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.7.1.tgz",
|
||||
"integrity": "sha512-6goBapMwyHcXvjLkCnFs4S3P1oKUi1H083BdPk4pDZALFWn5ZdG50ECNfHSddBmL3O0pyx1/WFq1C/MuR7Y54A==",
|
||||
"version": "6.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.8.0.tgz",
|
||||
"integrity": "sha512-gHcqPFSHdOz29tZiLlzDvD+Gfq21zVIFBufprSYTiHpUdcVNJEK6+V5aw++FtQncHdDWnTf2YCky/e1Bol2NEQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@x0k/json-schema-merge": "^1.0.3",
|
||||
"fast-equals": "^6.0.0",
|
||||
"fast-uri": "^4.1.1",
|
||||
"fast-uri": "^4.1.2",
|
||||
"jsonpointer": "^5.0.1",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
@@ -10380,9 +10380,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/validator-ajv8": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.7.1.tgz",
|
||||
"integrity": "sha512-oG9reR8VgUUTxfsO8WybZWTjKs6SLUdhmUCp55SXmJvwVbeKZ+Mz4SI+y+T1Mdpbm1kLZWQPRyF2Md97soWXkw==",
|
||||
"version": "6.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.8.0.tgz",
|
||||
"integrity": "sha512-F36I952/miMFZzWSlupwFHbl+j+5bVQ3tR6HtBS+vXV10kY5dT3OwTjbFRo4fQM1KpqrUZC0t3/E5CZRX1o1jA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
@@ -10394,7 +10394,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rjsf/utils": "^6.7.1"
|
||||
"@rjsf/utils": "^6.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
|
||||
@@ -122,9 +122,9 @@
|
||||
"@luma.gl/shadertools": "~9.2.5",
|
||||
"@luma.gl/webgl": "~9.2.5",
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@rjsf/core": "^6.7.1",
|
||||
"@rjsf/core": "^6.8.0",
|
||||
"@rjsf/utils": "^6.6.2",
|
||||
"@rjsf/validator-ajv8": "^6.7.1",
|
||||
"@rjsf/validator-ajv8": "^6.8.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
|
||||
"@superset-ui/core": "file:./packages/superset-ui-core",
|
||||
|
||||
@@ -122,6 +122,12 @@ export const timeComparisonControls: ({
|
||||
}
|
||||
return newState;
|
||||
},
|
||||
// Re-run this control's validation whenever `time_compare` changes so
|
||||
// the "date required" error clears once a non-custom shift is picked.
|
||||
// Without it the stale error survives in Redux (see the
|
||||
// dependantControls path in exploreReducer's SET_FIELD_VALUE handler)
|
||||
// and blocks further chart updates until a page refresh.
|
||||
validationDependencies: ['time_compare'],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -71,6 +71,9 @@ export type AntdExposedProps = Pick<
|
||||
| 'virtual'
|
||||
| 'getPopupContainer'
|
||||
| 'menuItemSelectedIcon'
|
||||
// lets a caller with long option labels stop the popup inheriting the
|
||||
// trigger's width, which otherwise truncates every option
|
||||
| 'popupMatchSelectWidth'
|
||||
>;
|
||||
|
||||
export type SelectOptionsType = Exclude<AntdProps['options'], undefined>;
|
||||
|
||||
@@ -96,16 +96,57 @@ export class Menu {
|
||||
itemText: string,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
const popup = await this.openSubmenu(submenuText, {
|
||||
timeout: options?.timeout,
|
||||
itemText,
|
||||
});
|
||||
|
||||
// Use dispatchEvent instead of click to bypass viewport and pointer interception
|
||||
// issues. Ant Design renders submenu popups in a portal that can be positioned
|
||||
// outside the viewport or behind chart content (e.g., large tables with z-index).
|
||||
await popup.getByText(itemText, { exact: true }).dispatchEvent('click');
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a submenu and returns its popup locator, without selecting an item.
|
||||
* Useful when the caller needs to read the popup's contents (e.g. the set of
|
||||
* offered items) rather than clicking a known item.
|
||||
*
|
||||
* Uses hover as primary approach, falls back to keyboard then dispatchEvent -
|
||||
* same fallback chain as {@link selectSubmenuItem}.
|
||||
*
|
||||
* @param submenuText - The text of the submenu to open (e.g., "Download")
|
||||
* @param options - Optional timeout, an `itemText` to scope the popup lookup
|
||||
* to (useful when multiple submenu popups could otherwise match), and a
|
||||
* `popupSelector` override for submenus that render with an additional,
|
||||
* more specific class than the generic Ant Design popup class.
|
||||
*/
|
||||
async openSubmenu(
|
||||
submenuText: string,
|
||||
options?: { timeout?: number; itemText?: string; popupSelector?: string },
|
||||
): Promise<Locator> {
|
||||
const timeout = options?.timeout ?? TIMEOUT.FORM_LOAD;
|
||||
const matchPopup = (): Locator => {
|
||||
const base = this.page.locator(
|
||||
options?.popupSelector ?? Menu.SELECTORS.SUBMENU_POPUP,
|
||||
);
|
||||
return options?.itemText
|
||||
? base.filter({ hasText: options.itemText })
|
||||
: base;
|
||||
};
|
||||
|
||||
// Try hover first (most natural user interaction)
|
||||
let popup = await this.openSubmenuWithHover(submenuText, itemText, timeout);
|
||||
let popup = await this.openSubmenuWithHover(
|
||||
submenuText,
|
||||
matchPopup,
|
||||
timeout,
|
||||
);
|
||||
|
||||
// Fallback to keyboard navigation
|
||||
if (!popup) {
|
||||
popup = await this.openSubmenuWithKeyboard(
|
||||
submenuText,
|
||||
itemText,
|
||||
matchPopup,
|
||||
timeout,
|
||||
);
|
||||
}
|
||||
@@ -114,7 +155,7 @@ export class Menu {
|
||||
if (!popup) {
|
||||
popup = await this.openSubmenuWithDispatchEvent(
|
||||
submenuText,
|
||||
itemText,
|
||||
matchPopup,
|
||||
timeout,
|
||||
);
|
||||
}
|
||||
@@ -125,10 +166,7 @@ export class Menu {
|
||||
);
|
||||
}
|
||||
|
||||
// Use dispatchEvent instead of click to bypass viewport and pointer interception
|
||||
// issues. Ant Design renders submenu popups in a portal that can be positioned
|
||||
// outside the viewport or behind chart content (e.g., large tables with z-index).
|
||||
await popup.getByText(itemText, { exact: true }).dispatchEvent('click');
|
||||
return popup;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,17 +175,14 @@ export class Menu {
|
||||
*/
|
||||
private async openSubmenuWithHover(
|
||||
submenuText: string,
|
||||
itemText: string,
|
||||
matchPopup: () => Locator,
|
||||
timeout: number,
|
||||
): Promise<Locator | null> {
|
||||
try {
|
||||
const submenuTitle = this.getSubmenuTitle(submenuText);
|
||||
await submenuTitle.hover();
|
||||
|
||||
// Find the popup that contains the expected item (scopes to correct popup)
|
||||
const popup = this.page
|
||||
.locator(Menu.SELECTORS.SUBMENU_POPUP)
|
||||
.filter({ hasText: itemText });
|
||||
const popup = matchPopup();
|
||||
await popup.waitFor({ state: 'visible', timeout });
|
||||
|
||||
// Allow Ant Design's slide-in animation to complete before clicking.
|
||||
@@ -166,7 +201,7 @@ export class Menu {
|
||||
*/
|
||||
private async openSubmenuWithKeyboard(
|
||||
submenuText: string,
|
||||
itemText: string,
|
||||
matchPopup: () => Locator,
|
||||
timeout: number,
|
||||
): Promise<Locator | null> {
|
||||
try {
|
||||
@@ -174,9 +209,7 @@ export class Menu {
|
||||
await submenuTitle.focus();
|
||||
await this.page.keyboard.press('ArrowRight');
|
||||
|
||||
const popup = this.page
|
||||
.locator(Menu.SELECTORS.SUBMENU_POPUP)
|
||||
.filter({ hasText: itemText });
|
||||
const popup = matchPopup();
|
||||
await popup.waitFor({ state: 'visible', timeout });
|
||||
|
||||
return popup;
|
||||
@@ -191,7 +224,7 @@ export class Menu {
|
||||
*/
|
||||
private async openSubmenuWithDispatchEvent(
|
||||
submenuText: string,
|
||||
itemText: string,
|
||||
matchPopup: () => Locator,
|
||||
timeout: number,
|
||||
): Promise<Locator | null> {
|
||||
try {
|
||||
@@ -214,9 +247,7 @@ export class Menu {
|
||||
);
|
||||
});
|
||||
|
||||
const popup = this.page
|
||||
.locator(Menu.SELECTORS.SUBMENU_POPUP)
|
||||
.filter({ hasText: itemText });
|
||||
const popup = matchPopup();
|
||||
await popup.waitFor({ state: 'visible', timeout });
|
||||
|
||||
return popup;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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 { Locator, Page } from '@playwright/test';
|
||||
import { Modal } from '../core';
|
||||
|
||||
/**
|
||||
* The "Drill to detail" modal (`DrillDetailModal.tsx`), opened from a chart's
|
||||
* "More Options" menu or its right-click context menu. Renders the chart's
|
||||
* underlying sample rows, optionally scoped to a drilled-by value, via the
|
||||
* `/datasource/samples` API.
|
||||
*/
|
||||
export class DrillDetailModal extends Modal {
|
||||
private static readonly SELECTORS = {
|
||||
CLOSE_BUTTON: '[data-test="close-drilltodetail-modal"]',
|
||||
ROW_COUNT_LABEL: '[data-test="row-count-label"]',
|
||||
METADATA_BAR: '[data-test="metadata-bar"]',
|
||||
FILTER_COLUMN: '[data-test="filter-col"]',
|
||||
FILTER_VALUE: '[data-test="filter-val"]',
|
||||
PAGE_ITEM: '.ant-pagination-item',
|
||||
ACTIVE_PAGE_ITEM: '.ant-pagination-item-active',
|
||||
GRID_CELL: '.virtual-table-cell',
|
||||
} as const;
|
||||
|
||||
private readonly specificLocator: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
// Matched by accessible name rather than a data-test: the antd Modal's own
|
||||
// data-test (`${name}-modal`) is derived from this same i18n'd `name`
|
||||
// prop, so it isn't a locale-independent alternative. No data-test exists
|
||||
// on the dialog root itself.
|
||||
this.specificLocator = page.getByRole('dialog', {
|
||||
name: /^Drill to detail:/,
|
||||
});
|
||||
}
|
||||
|
||||
override get element(): Locator {
|
||||
return this.specificLocator;
|
||||
}
|
||||
|
||||
/**
|
||||
* The applied-filter value tags (`<col>=<val>`). Empty when the drill was
|
||||
* whole-chart (no row/point-level filter applied).
|
||||
*/
|
||||
get filterValues(): Locator {
|
||||
return this.element.locator(DrillDetailModal.SELECTORS.FILTER_VALUE);
|
||||
}
|
||||
|
||||
/** The applied-filter chip(s); each is closable via its own "Close" icon. */
|
||||
get filterColumns(): Locator {
|
||||
return this.element.locator(DrillDetailModal.SELECTORS.FILTER_COLUMN);
|
||||
}
|
||||
|
||||
/** Row-count label above the results grid, e.g. "1-50 of 500 rows". */
|
||||
get rowCountLabel(): Locator {
|
||||
return this.element.locator(DrillDetailModal.SELECTORS.ROW_COUNT_LABEL);
|
||||
}
|
||||
|
||||
/** The metadata bar (column/row summary) shown once samples have loaded. */
|
||||
get metadataBar(): Locator {
|
||||
return this.element.locator(DrillDetailModal.SELECTORS.METADATA_BAR);
|
||||
}
|
||||
|
||||
/** Pagination page-number items below the results grid. */
|
||||
get pageItems(): Locator {
|
||||
return this.element.locator(DrillDetailModal.SELECTORS.PAGE_ITEM);
|
||||
}
|
||||
|
||||
/** The currently active pagination page-number item. */
|
||||
get activePageItem(): Locator {
|
||||
return this.element.locator(DrillDetailModal.SELECTORS.ACTIVE_PAGE_ITEM);
|
||||
}
|
||||
|
||||
/** Cells of the virtualized results grid. */
|
||||
get gridCells(): Locator {
|
||||
return this.element.locator(DrillDetailModal.SELECTORS.GRID_CELL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the first applied filter by clicking its chip's Close icon,
|
||||
* re-fetching the unfiltered samples.
|
||||
*/
|
||||
async clearFirstFilter(): Promise<void> {
|
||||
await this.filterColumns.first().getByLabel('Close').click();
|
||||
}
|
||||
|
||||
/** Navigates to the given 1-indexed pagination page. */
|
||||
async goToPage(pageNumber: number): Promise<void> {
|
||||
await this.pageItems.nth(pageNumber - 1).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetches the current samples query, resetting pagination to page 1.
|
||||
*
|
||||
* Matched by accessible name: the Reload icon carries an i18n'd
|
||||
* `aria-label` (`t('Reload')`) and no data-test, so this breaks in
|
||||
* non-English locales the same way `DrillDetailModal.tsx`'s dialog `name`
|
||||
* does above; the predecessor Cypress test used the same English string.
|
||||
*/
|
||||
async reload(): Promise<void> {
|
||||
await this.element.getByRole('button', { name: 'Reload' }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the modal via its footer Close button.
|
||||
*
|
||||
* Targets the button by data-test rather than Modal.clickFooterButton,
|
||||
* which finds buttons by their visible text. The button label is i18n'd
|
||||
* ("Close" / "Fermer" / …), so name-based lookups break in non-English
|
||||
* locales; see DeleteConfirmationModal.clickDelete for the same rationale.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
await this.element.locator(DrillDetailModal.SELECTORS.CLOSE_BUTTON).click();
|
||||
await this.waitForHidden();
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
export { ChartPropertiesModal } from './ChartPropertiesModal';
|
||||
export { ConfirmDialog } from './ConfirmDialog';
|
||||
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
||||
export { DrillDetailModal } from './DrillDetailModal';
|
||||
export { DuplicateDatasetModal } from './DuplicateDatasetModal';
|
||||
export { EditDatasetModal } from './EditDatasetModal';
|
||||
export { ImportDatasetModal } from './ImportDatasetModal';
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { Page, Download, Locator, expect } from '@playwright/test';
|
||||
import { Button, Input, Menu, Tabs } from '../components/core';
|
||||
import { DashboardFilterBar } from '../components/dashboard';
|
||||
import { DrillDetailModal } from '../components/modals';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { html5DragAndDrop } from '../helpers/dnd';
|
||||
import { TIMEOUT } from '../utils/constants';
|
||||
@@ -454,4 +455,124 @@ export class DashboardPage {
|
||||
|
||||
return { heightBefore: boxBefore.height, heightAfter: boxAfter.height };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drill to detail
|
||||
//
|
||||
// Charts that implement the DRILL_TO_DETAIL behavior expose two entry points:
|
||||
// the chart's "More Options" header menu, and a right-click context menu on
|
||||
// the chart body (a cell, the big-number value, or a canvas data point). Both
|
||||
// open the same DrillDetailModal, which renders the underlying sample rows for
|
||||
// the (optionally filtered) chart by calling the `/datasource/samples` API.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Open the "Drill to detail" item from a chart's "More Options" header menu.
|
||||
* This is the whole-chart entry point (no row-level filters applied).
|
||||
*/
|
||||
async openDrillToDetailFromMenu(chartId: number): Promise<void> {
|
||||
const moreOptions = new Button(
|
||||
this.page,
|
||||
this.getChart(chartId).getByLabel('More Options', { exact: true }),
|
||||
);
|
||||
await moreOptions.click();
|
||||
await this.page
|
||||
.getByRole('menuitem', { name: 'Drill to detail', exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* The DrillDetailModal dialog (titled "Drill to detail: <chart name>").
|
||||
*/
|
||||
drillModal(): DrillDetailModal {
|
||||
return new DrillDetailModal(this.page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the plain "Drill to detail" item in an open chart context menu
|
||||
* (whole chart, no row-level filter).
|
||||
*/
|
||||
async contextMenuDrillToDetail(): Promise<void> {
|
||||
await this.page
|
||||
.getByRole('menuitem', { name: 'Drill to detail', exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Drill to detail by" submenu parent (title) in an open context menu.
|
||||
* Targeted by its submenu-title element rather than role+name because antd
|
||||
* appends the arrow-icon name ("right") to the accessible name, and the leaf
|
||||
* items ("Drill to detail by boy") would otherwise match a role+name lookup.
|
||||
*/
|
||||
drillBySubmenuTitle(): Locator {
|
||||
return this.page.locator('.ant-dropdown-menu-submenu-title', {
|
||||
hasText: 'Drill to detail by',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The chart context menu's Menu component, scoped to the open context
|
||||
* menu's root. Used to open the "Drill to detail by" submenu robustly:
|
||||
* plain hover is not reliably picked up by Ant Design's submenu trigger in
|
||||
* headless Chromium, so this falls back to keyboard and dispatchEvent - see
|
||||
* {@link Menu.openSubmenu}.
|
||||
*/
|
||||
private contextMenu(): Menu {
|
||||
return new Menu(this.page, '[data-test="chart-context-menu"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the "Drill to detail by" submenu and returns its popup, containing
|
||||
* the leaf value items (e.g. "Drill to detail by boy").
|
||||
*/
|
||||
private openDrillBySubmenu(): Promise<Locator> {
|
||||
return this.contextMenu().openSubmenu('Drill to detail by', {
|
||||
popupSelector: '.chart-context-submenu',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* From an open chart context menu, open the "Drill to detail by" submenu and
|
||||
* click the entry for a specific value (e.g. "boy", "1965", "all").
|
||||
*/
|
||||
async contextMenuDrillToDetailBy(value: string): Promise<void> {
|
||||
const popup = await this.openDrillBySubmenu();
|
||||
// Use dispatchEvent instead of click to bypass viewport and pointer
|
||||
// interception issues - see Menu.selectSubmenuItem.
|
||||
await popup
|
||||
.getByRole('menuitem', {
|
||||
name: `Drill to detail by ${value}`,
|
||||
exact: true,
|
||||
})
|
||||
.dispatchEvent('click');
|
||||
}
|
||||
|
||||
/**
|
||||
* From an open chart context menu, open "Drill to detail by" and return the
|
||||
* concrete values offered by the submenu (e.g. ["1965", "boy"]), skipping the
|
||||
* aggregate "all" entry. Used by canvas charts where the value under the
|
||||
* cursor is data-dependent: the test drills by whatever the menu actually
|
||||
* offers and asserts that same value round-trips into the modal, which keeps
|
||||
* the assertion independent of exact pixel/slice geometry.
|
||||
*
|
||||
* Reads rendered (HTML-stripped) menu text rather than the item's
|
||||
* `aria-label`, which carries the raw, unstripped formatted value
|
||||
* (`useDrillDetailMenuItems`). The two only diverge for formatted values
|
||||
* that contain HTML markup; callers pass the returned value both to
|
||||
* `contextMenuDrillToDetailBy` (accessible-name lookup) and to a
|
||||
* displayed-text assertion on the modal's filter chip, so a value straddling
|
||||
* both uses only works when it's markup-free. Every value currently offered
|
||||
* by this dashboard's charts is a plain string, so this hasn't been
|
||||
* reachable in practice; revisit if a test starts exercising HTML-formatted
|
||||
* dimension values.
|
||||
*/
|
||||
async drillByOfferedValues(): Promise<string[]> {
|
||||
const popup = await this.openDrillBySubmenu();
|
||||
const items = popup.locator('[role="menuitem"]');
|
||||
await items.first().waitFor();
|
||||
const labels = await items.allInnerTexts();
|
||||
return labels
|
||||
.map(l => l.replace(/^Drill to detail by\s*/i, '').trim())
|
||||
.filter(v => v.length > 0 && v.toLowerCase() !== 'all');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* E2E migration of the Cypress "Drill to detail modal" suite
|
||||
* (dashboard/drilltodetail.test.ts).
|
||||
*
|
||||
* Drill to detail lets a viewer open a modal of the underlying sample rows for a
|
||||
* chart — optionally filtered to a single data point — by either the chart's
|
||||
* "More Options" header menu or a right-click context menu on the chart body.
|
||||
* The modal calls the real `/datasource/samples` API, so this is genuinely
|
||||
* end-to-end: each test API-builds a hermetic dashboard from the `birth_names`
|
||||
* dataset, renders it in the browser, drives the real menus, and asserts the
|
||||
* resulting backend round-trip (the samples POST and the filter the modal
|
||||
* applies).
|
||||
*
|
||||
* Why the original suite was fully `describe.skip`:
|
||||
* "it has issues with autoscrolling and the locked title flakes intricately
|
||||
* when the rightClick is obstructed by the title."
|
||||
* That failure mode is Cypress-specific — Cypress auto-scrolls the target under
|
||||
* the sticky chart header before every action. Playwright scrolls once and the
|
||||
* target stays put, so the entry points are portable here.
|
||||
*
|
||||
* What is migrated, and how it is kept deterministic:
|
||||
* - Modal mechanics (open from header menu, pagination, reload-resets-page)
|
||||
* and the no-filter big-number drill use stable DOM elements.
|
||||
* - Table and Pivot drills right-click real DOM cells (no canvas pixels).
|
||||
* - Canvas (echarts) charts — Pie, Line, Scatter, generic/smooth/step
|
||||
* time-series, Mixed, Box plot, Funnel, Gauge, Treemap — DID rely on
|
||||
* hard-coded pixel coordinates in Cypress to land on a specific slice/point.
|
||||
* Instead of reproducing those brittle pixels, these tests scan a stable
|
||||
* region of the canvas (see `rightClickCanvasDatum`), read whichever value
|
||||
* the drill submenu actually offers for the point under the cursor, drill by
|
||||
* that value, and assert the SAME value round-trips into the modal filter.
|
||||
* This exercises the full canvas → contextmenu → datum → samples pipeline
|
||||
* while staying independent of exact geometry. `Big Number with Trendline`
|
||||
* drills the whole chart (no datum filter), like `Big Number`.
|
||||
*
|
||||
* Excluded (kept out, matching the original's own `describe.skip`s): Bar, Area,
|
||||
* World Map, Radar — skipped upstream for chart-specific reasons.
|
||||
*/
|
||||
import {
|
||||
testWithAssets,
|
||||
expect,
|
||||
type TestAssets,
|
||||
} from '../../helpers/fixtures';
|
||||
import type { Page, TestInfo } from '@playwright/test';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
import { createDashboardWithCharts } from './dashboard-test-helpers';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
|
||||
/**
|
||||
* Parse a RowCountLabel value ("75.7k rows", "1,234 rows") into a number so
|
||||
* tests can assert the *invariant* (filtered < unfiltered) without hard-coding
|
||||
* the dataset-specific totals the original Cypress suite baked in.
|
||||
*/
|
||||
function parseRowCount(text: string): number {
|
||||
const m = text.match(/([\d.,]+)\s*([kKmM]?)/);
|
||||
if (!m) return NaN;
|
||||
let n = parseFloat(m[1].replace(/,/g, ''));
|
||||
const suffix = m[2].toLowerCase();
|
||||
if (suffix === 'k') n *= 1e3;
|
||||
if (suffix === 'm') n *= 1e6;
|
||||
return n;
|
||||
}
|
||||
|
||||
interface ChartSpec {
|
||||
vizType: string;
|
||||
chartNamePrefix: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* API-build a hermetic single-chart dashboard from birth_names and return its
|
||||
* dashboard and chart ids. Thin single-chart wrapper around
|
||||
* `createDashboardWithCharts`, the build helper shared by the other migrated
|
||||
* dashboard specs — reused here rather than hand-rolling position-json and id
|
||||
* extraction again.
|
||||
*/
|
||||
async function buildSingleChartDashboard(
|
||||
page: Page,
|
||||
testAssets: TestAssets,
|
||||
testInfo: TestInfo,
|
||||
spec: ChartSpec,
|
||||
): Promise<{ dashboardId: number; chartId: number }> {
|
||||
const { dashboardId, charts } = await createDashboardWithCharts(
|
||||
page,
|
||||
testAssets,
|
||||
testInfo,
|
||||
{
|
||||
datasetName: DATASET_NAME,
|
||||
chartNamePrefix: spec.chartNamePrefix,
|
||||
dashboardTitlePrefix: spec.chartNamePrefix,
|
||||
chartSpecs: [{ viz_type: spec.vizType, params: spec.params }],
|
||||
},
|
||||
);
|
||||
return { dashboardId, chartId: charts[0].id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-click an echarts canvas until a data point is hit — i.e. until the
|
||||
* context menu offers an *enabled* "Drill to detail by" submenu (a miss renders
|
||||
* that item disabled, as a plain menu item rather than a submenu title).
|
||||
*
|
||||
* echarts renders to a single canvas, so there is no per-datum DOM element to
|
||||
* target and the exact pixel of a mark depends on chart geometry (donut hole,
|
||||
* legend size, axis padding). Rather than hard-code Cypress's brittle pixel
|
||||
* coordinates, this scans a small set of candidate points — a radial ring for
|
||||
* pie/radial charts, a grid for cartesian charts — and stops at the first that
|
||||
* lands on a mark. The drill value is then whatever that mark represents, so the
|
||||
* caller asserts a value round-trip rather than a specific geometry.
|
||||
*/
|
||||
async function rightClickCanvasDatum(
|
||||
page: Page,
|
||||
dashboard: DashboardPage,
|
||||
canvas: ReturnType<Page['locator']>,
|
||||
pattern: 'ring' | 'grid' | 'dense',
|
||||
): Promise<void> {
|
||||
const box = await canvas.boundingBox();
|
||||
if (!box) throw new Error('canvas has no bounding box');
|
||||
|
||||
const ringPoints = (): Array<{ x: number; y: number }> => {
|
||||
const pts: Array<{ x: number; y: number }> = [];
|
||||
const cx = box.width / 2;
|
||||
const cy = box.height / 2;
|
||||
const minSide = Math.min(box.width, box.height);
|
||||
for (const rf of [0.3, 0.22, 0.38]) {
|
||||
for (let a = 0; a < 360; a += 45) {
|
||||
const rad = (a * Math.PI) / 180;
|
||||
pts.push({
|
||||
x: cx + Math.cos(rad) * minSide * rf,
|
||||
y: cy + Math.sin(rad) * minSide * rf,
|
||||
});
|
||||
}
|
||||
}
|
||||
return pts;
|
||||
};
|
||||
const gridPoints = (): Array<{ x: number; y: number }> => {
|
||||
const pts: Array<{ x: number; y: number }> = [];
|
||||
for (const yf of [0.5, 0.4, 0.6, 0.3, 0.7]) {
|
||||
for (const xf of [0.3, 0.45, 0.6, 0.2, 0.75]) {
|
||||
pts.push({ x: box.width * xf, y: box.height * yf });
|
||||
}
|
||||
}
|
||||
return pts;
|
||||
};
|
||||
|
||||
// 'dense' merges both scans for radial/stacked shapes (gauge, funnel, box
|
||||
// plot) whose drillable marks don't fall neatly on a single ring or grid.
|
||||
let candidates: Array<{ x: number; y: number }>;
|
||||
if (pattern === 'ring') candidates = ringPoints();
|
||||
else if (pattern === 'grid') candidates = gridPoints();
|
||||
else candidates = [...gridPoints(), ...ringPoints()];
|
||||
|
||||
// The submenu *title* element only exists when "Drill to detail by" is an
|
||||
// enabled submenu (a real datum was hit); a miss renders a disabled item.
|
||||
const enabledDrillBy = dashboard.drillBySubmenuTitle();
|
||||
const contextMenu = page.locator('[data-test="chart-context-menu"]');
|
||||
|
||||
for (const pt of candidates) {
|
||||
await canvas.click({ button: 'right', position: pt });
|
||||
const hit = await enabledDrillBy
|
||||
.waitFor({ state: 'visible', timeout: 400 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (hit) return;
|
||||
await page.keyboard.press('Escape');
|
||||
// Wait for the portal to actually close before the next right-click;
|
||||
// otherwise a still-open (or mid-close-animation) menu can make the
|
||||
// next click/locator behave nondeterministically on slower/contended CI.
|
||||
await contextMenu
|
||||
.waitFor({ state: 'hidden', timeout: 400 })
|
||||
.catch(() => {});
|
||||
}
|
||||
throw new Error(
|
||||
`no drillable datum found on canvas after scanning ${candidates.length} points`,
|
||||
);
|
||||
}
|
||||
|
||||
/** A samples POST fired (proves the modal hit the real backend). */
|
||||
function expectSamplesPost(page: Page) {
|
||||
return page.waitForResponse(
|
||||
r =>
|
||||
r.url().includes('/datasource/samples') &&
|
||||
r.request().method() === 'POST',
|
||||
{ timeout: TIMEOUT.API_RESPONSE },
|
||||
);
|
||||
}
|
||||
|
||||
async function loadDashboardWithChart(
|
||||
dashboard: DashboardPage,
|
||||
dashboardId: number,
|
||||
chartId: number,
|
||||
): Promise<void> {
|
||||
await dashboard.gotoById(dashboardId);
|
||||
await dashboard.waitForLoad();
|
||||
await dashboard
|
||||
.getChart(chartId)
|
||||
.locator('[data-test="chart-container"]')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: TIMEOUT.QUERY_EXECUTION });
|
||||
await dashboard.waitForChartsToLoad();
|
||||
}
|
||||
|
||||
/**
|
||||
* From an already-open "Drill to detail by" submenu, drill by the first
|
||||
* offered value and assert that same value lands in the modal filter. The
|
||||
* shared tail of every "drill by whatever value is under the cursor" test —
|
||||
* canvas charts and the pivot table alike, which differ only in how they open
|
||||
* the submenu in the first place.
|
||||
*/
|
||||
async function drillByFirstOfferedValueAndAssert(
|
||||
page: Page,
|
||||
dashboard: DashboardPage,
|
||||
): Promise<void> {
|
||||
const offered = await dashboard.drillByOfferedValues();
|
||||
expect(offered.length).toBeGreaterThan(0);
|
||||
const [value] = offered;
|
||||
const samples = expectSamplesPost(page);
|
||||
await dashboard.contextMenuDrillToDetailBy(value);
|
||||
await samples;
|
||||
|
||||
await expect(dashboard.drillModal().element).toBeVisible();
|
||||
await expect(dashboard.drillModal().filterValues.first()).toContainText(
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full canvas-drill round-trip for an echarts (canvas-rendered) chart: build a
|
||||
* hermetic single-chart dashboard, render it, right-click a real datum, drill by
|
||||
* whatever value the submenu offers under the cursor, and assert that same value
|
||||
* lands in the modal filter. Geometry-independent — see rightClickCanvasDatum.
|
||||
* Reused across every canvas viz type so each migrated chart is a thin caller.
|
||||
*/
|
||||
async function expectCanvasDrillByValueRoundTrips(
|
||||
page: Page,
|
||||
testAssets: TestAssets,
|
||||
testInfo: TestInfo,
|
||||
spec: ChartSpec,
|
||||
pattern: 'ring' | 'grid' | 'dense',
|
||||
): Promise<void> {
|
||||
const dashboard = new DashboardPage(page);
|
||||
const { dashboardId, chartId } = await buildSingleChartDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testInfo,
|
||||
spec,
|
||||
);
|
||||
await loadDashboardWithChart(dashboard, dashboardId, chartId);
|
||||
|
||||
const canvas = dashboard.getChart(chartId).locator('canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
await rightClickCanvasDatum(page, dashboard, canvas, pattern);
|
||||
|
||||
await drillByFirstOfferedValueAndAssert(page, dashboard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-click a big-number chart's rendered value to open its context menu,
|
||||
* drill the whole chart (no row/point filter), and assert the modal opened
|
||||
* with no filter tags and a real row count. Shared by Big Number and Big
|
||||
* Number with Trendline, which differ only in their chart params.
|
||||
*/
|
||||
async function expectWholeChartDrillFromContextMenu(
|
||||
page: Page,
|
||||
dashboard: DashboardPage,
|
||||
chartId: number,
|
||||
): Promise<void> {
|
||||
const samples = expectSamplesPost(page);
|
||||
await dashboard
|
||||
.getChart(chartId)
|
||||
.locator('.header-line')
|
||||
.click({ button: 'right' });
|
||||
await dashboard.contextMenuDrillToDetail();
|
||||
await samples;
|
||||
|
||||
await expect(dashboard.drillModal().element).toBeVisible();
|
||||
// Whole-chart drill: no per-value filter tag.
|
||||
await expect(dashboard.drillModal().filterValues).toHaveCount(0);
|
||||
await expect(dashboard.drillModal().rowCountLabel).toContainText('rows');
|
||||
}
|
||||
|
||||
// Shared form-data fragment for the echarts time-series family (line/scatter/
|
||||
// generic/smooth/step): one temporal axis, one metric, split by gender series.
|
||||
const TIMESERIES_PARAMS = {
|
||||
x_axis: 'ds',
|
||||
time_grain_sqla: 'P1Y',
|
||||
metrics: ['count'],
|
||||
groupby: ['gender'],
|
||||
row_limit: 1000,
|
||||
};
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: opens from the header menu, paginates, and reload resets to page 1',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
const dashboard = new DashboardPage(page);
|
||||
const { dashboardId, chartId } = await buildSingleChartDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'big_number_total',
|
||||
chartNamePrefix: 'drill_bignum',
|
||||
params: { metric: 'count', adhoc_filters: [] },
|
||||
},
|
||||
);
|
||||
|
||||
await loadDashboardWithChart(dashboard, dashboardId, chartId);
|
||||
|
||||
// Open the modal from the chart's "More Options" header menu.
|
||||
const samplesOnOpen = expectSamplesPost(page);
|
||||
await dashboard.openDrillToDetailFromMenu(chartId);
|
||||
await samplesOnOpen;
|
||||
|
||||
const modal = dashboard.drillModal();
|
||||
await expect(modal.element).toBeVisible();
|
||||
await expect(modal.element).toContainText('Drill to detail:');
|
||||
// The metadata bar and a real row count prove the modal loaded backend data.
|
||||
await expect(modal.metadataBar).toBeVisible();
|
||||
await expect(modal.rowCountLabel).toContainText('rows');
|
||||
// No drill filter was applied (whole-chart drill).
|
||||
await expect(modal.filterValues).toHaveCount(0);
|
||||
|
||||
// The full dataset spans multiple pages, and the grid has rendered rows.
|
||||
expect(await modal.pageItems.count()).toBeGreaterThan(1);
|
||||
await expect(modal.gridCells.first()).toBeVisible();
|
||||
await expect(modal.activePageItem).toContainText('1');
|
||||
|
||||
// Paginate forward: clicking page 2 fires a real samples fetch and moves the
|
||||
// active page to 2.
|
||||
const samplesOnPage2 = expectSamplesPost(page);
|
||||
await modal.goToPage(2);
|
||||
await samplesOnPage2;
|
||||
await expect(modal.activePageItem).toContainText('2');
|
||||
|
||||
// Reload re-fetches and resets back to the first page.
|
||||
const samplesOnReload = expectSamplesPost(page);
|
||||
await modal.reload();
|
||||
await samplesOnReload;
|
||||
await expect(modal.activePageItem).toContainText('1');
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: big number value right-click drills the whole chart (no filter)',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
const dashboard = new DashboardPage(page);
|
||||
const { dashboardId, chartId } = await buildSingleChartDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'big_number_total',
|
||||
chartNamePrefix: 'drill_bignum_rc',
|
||||
params: { metric: 'count', adhoc_filters: [] },
|
||||
},
|
||||
);
|
||||
|
||||
await loadDashboardWithChart(dashboard, dashboardId, chartId);
|
||||
await expectWholeChartDrillFromContextMenu(page, dashboard, chartId);
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: table cell right-click drills by that value and clearing the filter restores the full set',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
const dashboard = new DashboardPage(page);
|
||||
const { dashboardId, chartId } = await buildSingleChartDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'table',
|
||||
chartNamePrefix: 'drill_table',
|
||||
params: {
|
||||
query_mode: 'aggregate',
|
||||
groupby: ['gender'],
|
||||
metrics: ['count'],
|
||||
row_limit: 100,
|
||||
server_pagination: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await loadDashboardWithChart(dashboard, dashboardId, chartId);
|
||||
|
||||
// Right-click the "boy" dimension cell and drill by it.
|
||||
const samplesOnDrill = expectSamplesPost(page);
|
||||
await dashboard
|
||||
.getChart(chartId)
|
||||
.getByText('boy', { exact: true })
|
||||
.first()
|
||||
.click({ button: 'right' });
|
||||
await dashboard.contextMenuDrillToDetailBy('boy');
|
||||
await samplesOnDrill;
|
||||
|
||||
const modal = dashboard.drillModal();
|
||||
await expect(modal.element).toBeVisible();
|
||||
await expect(modal.filterValues.first()).toContainText('boy');
|
||||
|
||||
const filteredCount = parseRowCount(await modal.rowCountLabel.innerText());
|
||||
expect(filteredCount).toBeGreaterThan(0);
|
||||
|
||||
// Clearing the filter reloads the samples and restores the larger, unfiltered total.
|
||||
const samplesOnClear = expectSamplesPost(page);
|
||||
await modal.clearFirstFilter();
|
||||
await samplesOnClear;
|
||||
await expect(modal.filterValues).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () => parseRowCount(await modal.rowCountLabel.innerText()))
|
||||
.toBeGreaterThan(filteredCount);
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: pivot table cell right-click drills by the cell value',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
const dashboard = new DashboardPage(page);
|
||||
const { dashboardId, chartId } = await buildSingleChartDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'pivot_table_v2',
|
||||
chartNamePrefix: 'drill_pivot',
|
||||
params: {
|
||||
groupbyRows: ['gender'],
|
||||
groupbyColumns: [],
|
||||
metrics: ['count'],
|
||||
aggregateFunction: 'Sum',
|
||||
rowTotals: false,
|
||||
colTotals: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await loadDashboardWithChart(dashboard, dashboardId, chartId);
|
||||
|
||||
await dashboard
|
||||
.getChart(chartId)
|
||||
.locator('[role="gridcell"]')
|
||||
.first()
|
||||
.click({ button: 'right' });
|
||||
|
||||
// The cell's row dimension determines the offered value; drill by it and
|
||||
// assert the same value lands in the modal filter.
|
||||
await drillByFirstOfferedValueAndAssert(page, dashboard);
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: pie slice right-click (canvas) drills by the slice value',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
// Pie is a donut by default (center is a hole), so scan the ring for a slice.
|
||||
await expectCanvasDrillByValueRoundTrips(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'pie',
|
||||
chartNamePrefix: 'drill_pie',
|
||||
params: { groupby: ['gender'], metric: 'count' },
|
||||
},
|
||||
'ring',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: line chart point right-click (canvas) drills by the point value',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
// Scan the plot grid for a point on one of the series lines.
|
||||
await expectCanvasDrillByValueRoundTrips(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'echarts_timeseries_line',
|
||||
chartNamePrefix: 'drill_line',
|
||||
params: TIMESERIES_PARAMS,
|
||||
},
|
||||
'grid',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: big number with trendline right-click drills the whole chart (no filter)',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
const dashboard = new DashboardPage(page);
|
||||
const { dashboardId, chartId } = await buildSingleChartDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'big_number',
|
||||
chartNamePrefix: 'drill_bignum_trend',
|
||||
params: {
|
||||
metric: 'count',
|
||||
x_axis: 'ds',
|
||||
time_grain_sqla: 'P1Y',
|
||||
adhoc_filters: [],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await loadDashboardWithChart(dashboard, dashboardId, chartId);
|
||||
await expectWholeChartDrillFromContextMenu(page, dashboard, chartId);
|
||||
},
|
||||
);
|
||||
|
||||
interface CanvasDrillCase {
|
||||
title: string;
|
||||
spec: ChartSpec;
|
||||
pattern: 'ring' | 'grid' | 'dense';
|
||||
}
|
||||
|
||||
// Every remaining canvas (echarts) chart is a thin caller of
|
||||
// expectCanvasDrillByValueRoundTrips, differing only in viz type, chart
|
||||
// params, and which point-scan pattern finds a drillable mark.
|
||||
const CANVAS_DRILL_CASES: CanvasDrillCase[] = [
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: scatter chart point right-click (canvas) drills by the point value',
|
||||
spec: {
|
||||
vizType: 'echarts_timeseries_scatter',
|
||||
chartNamePrefix: 'drill_scatter',
|
||||
// Enlarge the markers so a region scan reliably lands on a point;
|
||||
// scatter's default dots are a few pixels wide and a sparse grid misses
|
||||
// them.
|
||||
params: { ...TIMESERIES_PARAMS, markerSize: 20 },
|
||||
},
|
||||
pattern: 'dense',
|
||||
},
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: generic time-series point right-click (canvas) drills by the point value',
|
||||
spec: {
|
||||
vizType: 'echarts_timeseries',
|
||||
chartNamePrefix: 'drill_generic',
|
||||
params: TIMESERIES_PARAMS,
|
||||
},
|
||||
pattern: 'grid',
|
||||
},
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: smooth line point right-click (canvas) drills by the point value',
|
||||
spec: {
|
||||
vizType: 'echarts_timeseries_smooth',
|
||||
chartNamePrefix: 'drill_smooth',
|
||||
params: TIMESERIES_PARAMS,
|
||||
},
|
||||
pattern: 'grid',
|
||||
},
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: step line point right-click (canvas) drills by the point value',
|
||||
spec: {
|
||||
vizType: 'echarts_timeseries_step',
|
||||
chartNamePrefix: 'drill_step',
|
||||
params: TIMESERIES_PARAMS,
|
||||
},
|
||||
pattern: 'grid',
|
||||
},
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: mixed time-series point right-click (canvas) drills by the point value',
|
||||
spec: {
|
||||
vizType: 'mixed_timeseries',
|
||||
chartNamePrefix: 'drill_mixed',
|
||||
params: {
|
||||
x_axis: 'ds',
|
||||
time_grain_sqla: 'P1Y',
|
||||
metrics: ['count'],
|
||||
groupby: ['gender'],
|
||||
metrics_b: ['count'],
|
||||
groupby_b: ['gender'],
|
||||
row_limit: 1000,
|
||||
},
|
||||
},
|
||||
pattern: 'grid',
|
||||
},
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: box plot right-click (canvas) drills by the box value',
|
||||
spec: {
|
||||
vizType: 'box_plot',
|
||||
chartNamePrefix: 'drill_boxplot',
|
||||
params: {
|
||||
groupby: ['gender'],
|
||||
metrics: ['count'],
|
||||
columns: ['ds'],
|
||||
},
|
||||
},
|
||||
pattern: 'dense',
|
||||
},
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: funnel segment right-click (canvas) drills by the segment value',
|
||||
spec: {
|
||||
vizType: 'funnel',
|
||||
chartNamePrefix: 'drill_funnel',
|
||||
params: { groupby: ['gender'], metric: 'count' },
|
||||
},
|
||||
pattern: 'dense',
|
||||
},
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: gauge right-click (canvas) drills by the gauge value',
|
||||
spec: {
|
||||
vizType: 'gauge_chart',
|
||||
chartNamePrefix: 'drill_gauge',
|
||||
params: { groupby: ['gender'], metric: 'count' },
|
||||
},
|
||||
pattern: 'dense',
|
||||
},
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: treemap tile right-click (canvas) drills by the tile value',
|
||||
spec: {
|
||||
vizType: 'treemap_v2',
|
||||
chartNamePrefix: 'drill_treemap',
|
||||
params: { metric: 'count', groupby: ['gender'] },
|
||||
},
|
||||
pattern: 'dense',
|
||||
},
|
||||
];
|
||||
|
||||
for (const { title, spec, pattern } of CANVAS_DRILL_CASES) {
|
||||
testWithAssets(title, async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
await expectCanvasDrillByValueRoundTrips(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
spec,
|
||||
pattern,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: drilling a time-series point "by all" applies every dimension of that point',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
const dashboard = new DashboardPage(page);
|
||||
const { dashboardId, chartId } = await buildSingleChartDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'echarts_timeseries_line',
|
||||
chartNamePrefix: 'drill_all',
|
||||
// Two groupby dimensions so each point genuinely carries more than one
|
||||
// drillable value — the whole point of "Drill to detail by all".
|
||||
params: { ...TIMESERIES_PARAMS, groupby: ['gender', 'state'] },
|
||||
},
|
||||
);
|
||||
|
||||
await loadDashboardWithChart(dashboard, dashboardId, chartId);
|
||||
|
||||
const canvas = dashboard.getChart(chartId).locator('canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
await rightClickCanvasDatum(page, dashboard, canvas, 'grid');
|
||||
|
||||
// A line point carries two dimensions (the temporal value and the gender
|
||||
// series), so "Drill to detail by all" must apply both as filters.
|
||||
const offered = await dashboard.drillByOfferedValues();
|
||||
expect(offered.length).toBeGreaterThanOrEqual(2);
|
||||
const samples = expectSamplesPost(page);
|
||||
await dashboard.contextMenuDrillToDetailBy('all');
|
||||
await samples;
|
||||
|
||||
await expect(dashboard.drillModal().element).toBeVisible();
|
||||
expect(
|
||||
await dashboard.drillModal().filterValues.count(),
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: table drills correctly by each of multiple dimension values',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
const dashboard = new DashboardPage(page);
|
||||
const { dashboardId, chartId } = await buildSingleChartDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'table',
|
||||
chartNamePrefix: 'drill_table_multi',
|
||||
params: {
|
||||
query_mode: 'aggregate',
|
||||
groupby: ['gender'],
|
||||
metrics: ['count'],
|
||||
row_limit: 100,
|
||||
server_pagination: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await loadDashboardWithChart(dashboard, dashboardId, chartId);
|
||||
|
||||
for (const value of ['boy', 'girl']) {
|
||||
const samples = expectSamplesPost(page);
|
||||
await dashboard
|
||||
.getChart(chartId)
|
||||
.getByText(value, { exact: true })
|
||||
.first()
|
||||
.click({ button: 'right' });
|
||||
await dashboard.contextMenuDrillToDetailBy(value);
|
||||
await samples;
|
||||
|
||||
const modal = dashboard.drillModal();
|
||||
await expect(modal.element).toBeVisible();
|
||||
await expect(modal.filterValues.first()).toContainText(value);
|
||||
await modal.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -40,10 +40,18 @@ import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiGet } from '../../helpers/api/requests';
|
||||
import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { getAccessToken } from '../../helpers/api/embedded';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
|
||||
async function authorizeApi(page: Page): Promise<void> {
|
||||
const accessToken = await getAccessToken(page);
|
||||
await page.context().setExtraHTTPHeaders({
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Visible row text must never expose synthetic identifiers (layout node
|
||||
// ids like CHART-xyz / ROW-… or bare UUIDs) — the rendering layer maps
|
||||
// these to human names or kind-only phrasing.
|
||||
@@ -79,19 +87,35 @@ async function currentUserSubjectId(page: Page): Promise<number> {
|
||||
* this reads like its sibling specs, but fall back to whatever the instance
|
||||
* has rather than requiring a particular fixture to be loaded.
|
||||
*/
|
||||
async function anyDatasetId(page: Page): Promise<number> {
|
||||
async function anyDataset(page: Page): Promise<{
|
||||
id: number;
|
||||
columnName: string;
|
||||
}> {
|
||||
const named = await getDatasetByName(page, DATASET_NAME);
|
||||
if (named) {
|
||||
return named.id;
|
||||
let datasetId = named?.id;
|
||||
if (!datasetId) {
|
||||
const res = await apiGet(
|
||||
page,
|
||||
`api/v1/dataset/?q=${rison.encode({ columns: ['id'], page_size: 1 })}`,
|
||||
);
|
||||
expect(res.ok(), 'dataset list request').toBeTruthy();
|
||||
const [first] = (await res.json()).result;
|
||||
expect(first, 'the instance has at least one dataset').toBeTruthy();
|
||||
datasetId = first.id;
|
||||
}
|
||||
const res = await apiGet(
|
||||
page,
|
||||
`api/v1/dataset/?q=${rison.encode({ columns: ['id'], page_size: 1 })}`,
|
||||
);
|
||||
expect(res.ok(), 'dataset list request').toBeTruthy();
|
||||
const [first] = (await res.json()).result;
|
||||
expect(first, 'the instance has at least one dataset').toBeTruthy();
|
||||
return first.id;
|
||||
if (datasetId === undefined) {
|
||||
throw new Error('Unable to resolve a dataset id');
|
||||
}
|
||||
|
||||
const detailRes = await apiGet(page, `api/v1/dataset/${datasetId}`);
|
||||
expect(detailRes.ok(), 'dataset detail request').toBeTruthy();
|
||||
const { columns } = (await detailRes.json()).result;
|
||||
const [firstColumn] = columns;
|
||||
expect(firstColumn, 'the dataset has at least one column').toBeTruthy();
|
||||
return {
|
||||
id: datasetId,
|
||||
columnName: firstColumn.column_name,
|
||||
};
|
||||
}
|
||||
|
||||
/** Open the Explore "Additional actions → View version history" panel. */
|
||||
@@ -109,7 +133,8 @@ testWithAssets(
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
const datasetId = await anyDatasetId(page);
|
||||
await authorizeApi(page);
|
||||
const { id: datasetId, columnName } = await anyDataset(page);
|
||||
|
||||
const baseName = `version_history_${Date.now()}`;
|
||||
const chartResp = await apiPostChart(page, {
|
||||
@@ -123,7 +148,7 @@ testWithAssets(
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'table',
|
||||
query_mode: 'raw',
|
||||
all_columns: [],
|
||||
all_columns: [columnName],
|
||||
adhoc_filters: [],
|
||||
row_limit: 10,
|
||||
}),
|
||||
@@ -171,3 +196,79 @@ testWithAssets(
|
||||
).toBeFalsy();
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'minor edit of a non-canonical chart omits hydration noise',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
await authorizeApi(page);
|
||||
const { id: datasetId, columnName } = await anyDataset(page);
|
||||
const baseName = `version_history_normalization_${Date.now()}`;
|
||||
const chartResp = await apiPostChart(page, {
|
||||
slice_name: baseName,
|
||||
viz_type: 'table',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
// Deliberately omit visualization defaults. Explore hydration supplies
|
||||
// them, reproducing params imported before they were canonical.
|
||||
params: JSON.stringify({
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'table',
|
||||
query_mode: 'raw',
|
||||
all_columns: [columnName],
|
||||
adhoc_filters: [],
|
||||
extra_form_data: {},
|
||||
dashboards: [],
|
||||
row_limit: 10,
|
||||
}),
|
||||
});
|
||||
expect(chartResp.ok(), 'chart creation').toBeTruthy();
|
||||
const chartBody = await chartResp.json();
|
||||
const chartId: number = chartBody.result?.id ?? chartBody.id;
|
||||
expect(chartId, 'chart creation should return an id').toBeTruthy();
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
const adminSubjectId = await currentUserSubjectId(page);
|
||||
const editorResp = await apiPutChart(page, chartId, {
|
||||
editors: [adminSubjectId],
|
||||
});
|
||||
expect(editorResp.ok(), 'claim chart editorship').toBeTruthy();
|
||||
|
||||
await page.goto(`explore/?slice_id=${chartId}`);
|
||||
await page.getByRole('combobox', { name: 'Row limit' }).click();
|
||||
await page.getByRole('option', { name: '100', exact: true }).click();
|
||||
await page.locator('[data-test="query-save-button"]').click();
|
||||
await page.locator('[data-test="save-overwrite-radio"]').click();
|
||||
|
||||
const saveResponsePromise = page.waitForResponse(
|
||||
response =>
|
||||
response.request().method() === 'PUT' &&
|
||||
response.url().includes(`/api/v1/chart/${chartId}`),
|
||||
);
|
||||
await page.locator('[data-test="btn-modal-save"]').click();
|
||||
const saveResponse = await saveResponsePromise;
|
||||
expect(saveResponse.ok(), 'chart overwrite').toBeTruthy();
|
||||
|
||||
const requestPayload = saveResponse.request().postDataJSON();
|
||||
const savedParams = JSON.parse(requestPayload.params);
|
||||
expect(
|
||||
savedParams.matrixify_enable,
|
||||
'overwrite contains a default absent from the stored params',
|
||||
).toBe(false);
|
||||
|
||||
await openVersionHistory(page);
|
||||
const panel = page.locator('[aria-label="Version history"]');
|
||||
const newestGroup = panel
|
||||
.locator('[data-test="version-history-save-group"]')
|
||||
.first();
|
||||
await expect(newestGroup, 'shows the overwrite save group').toBeVisible();
|
||||
await newestGroup.getByRole('button').first().click();
|
||||
|
||||
const rows = newestGroup.locator(
|
||||
'[data-test="version-history-action-row"]',
|
||||
);
|
||||
await expect(rows, 'shows only the intentional edit').toHaveCount(1);
|
||||
await expect(rows.first()).toContainText(/row limit/i);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -102,15 +102,18 @@ export default function buildQuery(formData: QueryFormData) {
|
||||
1. The resample, rolling, cum, timeCompare operators should be after pivot.
|
||||
2. Resample must come before rolling so that imputed values are
|
||||
included in the rolling window calculation.
|
||||
3. the flatOperator makes multiIndex Dataframe into flat Dataframe
|
||||
3. Contribution must come before rename because it relies on the
|
||||
`__<time offset>` suffix to compute each time shift separately,
|
||||
and rename strips that suffix.
|
||||
4. the flatOperator makes multiIndex Dataframe into flat Dataframe
|
||||
*/
|
||||
post_processing: [
|
||||
pivotOperatorInRuntime,
|
||||
resampleOperator(formData, baseQueryObject),
|
||||
rollingWindowOperator(formData, baseQueryObject),
|
||||
timeCompareOperator(formData, baseQueryObject),
|
||||
renameOperator(formData, baseQueryObject),
|
||||
contributionOperator(formData, baseQueryObject, time_offsets),
|
||||
renameOperator(formData, baseQueryObject),
|
||||
sortOperator(formData, baseQueryObject),
|
||||
flattenOperator(formData, baseQueryObject),
|
||||
// todo: move prophet before flatten
|
||||
|
||||
@@ -64,6 +64,28 @@ describe('Timeseries buildQuery', () => {
|
||||
expect(query.metrics).toEqual(['bar', 'baz']);
|
||||
});
|
||||
|
||||
test('should apply contribution before rename with time comparison', () => {
|
||||
// rename strips the `__<offset>` suffix that contribution relies on to
|
||||
// compute each time shift separately
|
||||
const queryContext = buildQuery({
|
||||
...formData,
|
||||
metrics: ['bar'],
|
||||
x_axis: 'ds',
|
||||
groupby: ['col1'],
|
||||
contributionMode: 'row',
|
||||
comparison_type: 'values',
|
||||
time_compare: ['1 week ago'],
|
||||
});
|
||||
const [query] = queryContext.queries;
|
||||
const operations = (query.post_processing || []).map(
|
||||
operator => operator?.operation,
|
||||
);
|
||||
expect(operations).toContain('contribution');
|
||||
expect(operations.indexOf('contribution')).toBeLessThan(
|
||||
operations.indexOf('rename'),
|
||||
);
|
||||
});
|
||||
|
||||
test('should not order by timeseries limit if orderby provided', () => {
|
||||
const queryContext = buildQuery({
|
||||
...formData,
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
includeSeries,
|
||||
isDarkMode: isThemeDark(theme),
|
||||
linearColorScheme,
|
||||
metrics: metrics.map((m: { label?: string } | string) =>
|
||||
metrics: (metrics ?? []).map((m: { label?: string } | string) =>
|
||||
typeof m === 'string' ? m : m.label || m,
|
||||
),
|
||||
colorMetric: secondaryMetric?.label || secondaryMetric,
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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 { ChartProps } from '@superset-ui/core';
|
||||
import transformProps from '../src/transformProps';
|
||||
|
||||
const createProps = () =>
|
||||
({
|
||||
width: 800,
|
||||
height: 600,
|
||||
formData: {
|
||||
includeSeries: false,
|
||||
linearColorScheme: 'superset_seq_1',
|
||||
metrics: undefined,
|
||||
secondaryMetric: 'sum__SP_POP_TOTL',
|
||||
series: 'country_name',
|
||||
showDatatable: false,
|
||||
},
|
||||
queriesData: [{ data: [{ country_id: 'FRA', metric: 10 }] }],
|
||||
theme: {},
|
||||
}) as unknown as ChartProps;
|
||||
|
||||
test('do not crash on undefined metrics', () => {
|
||||
expect(() => transformProps(createProps())).not.toThrow();
|
||||
});
|
||||
@@ -1069,10 +1069,10 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
const originKey = column.key.substring(column.label.length).trim();
|
||||
if (!hasColumnColorFormatters && hasBasicColorFormatters) {
|
||||
backgroundColor =
|
||||
basicColorFormatters[row.index][originKey]?.backgroundColor;
|
||||
basicColorFormatters[row.index]?.[originKey]?.backgroundColor;
|
||||
arrow =
|
||||
column.label === comparisonLabels[0]
|
||||
? basicColorFormatters[row.index][originKey]?.mainArrow
|
||||
? basicColorFormatters[row.index]?.[originKey]?.mainArrow
|
||||
: '';
|
||||
}
|
||||
|
||||
@@ -1134,11 +1134,11 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
basicColorColumnFormatters?.length > 0
|
||||
) {
|
||||
backgroundColor =
|
||||
basicColorColumnFormatters[row.index][column.key]
|
||||
basicColorColumnFormatters[row.index]?.[column.key]
|
||||
?.backgroundColor || backgroundColor;
|
||||
arrow =
|
||||
column.label === comparisonLabels[0]
|
||||
? basicColorColumnFormatters[row.index][column.key]?.mainArrow
|
||||
? basicColorColumnFormatters[row.index]?.[column.key]?.mainArrow
|
||||
: '';
|
||||
}
|
||||
const rowSurfaceColor =
|
||||
@@ -1197,7 +1197,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
let arrowStyles = css`
|
||||
color: ${
|
||||
basicColorFormatters &&
|
||||
basicColorFormatters[row.index][originKey]?.arrowColor ===
|
||||
basicColorFormatters[row.index]?.[originKey]?.arrowColor ===
|
||||
ColorSchemeEnum.Green
|
||||
? theme.colorSuccess
|
||||
: theme.colorError
|
||||
@@ -1211,7 +1211,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
) {
|
||||
arrowStyles = css`
|
||||
color: ${
|
||||
basicColorColumnFormatters[row.index][column.key]
|
||||
basicColorColumnFormatters[row.index]?.[column.key]
|
||||
?.arrowColor === ColorSchemeEnum.Green
|
||||
? theme.colorSuccess
|
||||
: theme.colorError
|
||||
|
||||
@@ -20,6 +20,7 @@ import '@testing-library/jest-dom';
|
||||
import {
|
||||
getTextColorForBackground,
|
||||
ObjectFormattingEnum,
|
||||
ColorSchemeEnum,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
@@ -2075,6 +2076,59 @@ describe('plugin-chart-table', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('does not crash when a comparison-color-formatter array has no entry for a rendered row', () => {
|
||||
// Regression test: the per-cell comparison-color lookups in the Cell
|
||||
// renderer (`basicColorFormatters`/`basicColorColumnFormatters`,
|
||||
// indexed by `row.index`) must stay safe even if those arrays ever
|
||||
// end up with fewer entries than the number of rendered rows -- e.g.
|
||||
// when "Show summary" is combined with time comparison and a
|
||||
// comparison-based conditional color scheme ("Green for increase,
|
||||
// red for decrease") applied to a Time Comparison column. Without
|
||||
// the `?.` guard on the array-index lookup, this throws
|
||||
// `TypeError: Cannot read properties of undefined (reading 'Main
|
||||
// metric_1')`.
|
||||
const propsInput = {
|
||||
...testData.comparison,
|
||||
rawFormData: {
|
||||
...testData.comparison.rawFormData,
|
||||
conditional_formatting: [
|
||||
{ column: 'Main metric_1', colorScheme: ColorSchemeEnum.Green },
|
||||
],
|
||||
},
|
||||
};
|
||||
const transformedProps = transformProps(propsInput);
|
||||
expect(transformedProps.data).toHaveLength(2);
|
||||
expect(transformedProps.basicColorColumnFormatters).toHaveLength(2);
|
||||
|
||||
// Simulate the row-count mismatch: the formatter array has an entry
|
||||
// for only the first row, matching the shape of the bug (an entry
|
||||
// missing for one of the rendered rows).
|
||||
const propsWithMissingFormatterEntry = {
|
||||
...transformedProps,
|
||||
basicColorColumnFormatters:
|
||||
transformedProps.basicColorColumnFormatters!.slice(0, 1),
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
render(
|
||||
<TableChart {...propsWithMissingFormatterEntry} sticky={false} />,
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
// the row that still has a formatter entry keeps its comparison
|
||||
// background color and arrow: the "Main metric_1" cell for the
|
||||
// first row (value 100) renders before the derived "△ metric_1"
|
||||
// cell that happens to share the same value and aria label.
|
||||
const [styledCell] = screen.getAllByTitle('100');
|
||||
expect(styledCell).toHaveTextContent('↑100');
|
||||
expect(getComputedStyle(styledCell).background).toContain(
|
||||
'rgba(0, 150, 0, 0.2)',
|
||||
);
|
||||
|
||||
// the row missing a formatter entry still renders its raw value
|
||||
expect(screen.getAllByTitle('110').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('preserves client-side search text across temporal table rerenders', async () => {
|
||||
const formDataWithSearch = {
|
||||
...testData.basic.formData,
|
||||
|
||||
-17
@@ -43,23 +43,6 @@ describe('SaveDatasetActionButton', () => {
|
||||
expect(saveDatasetBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('disables only the dataset button when canSaveDataset is false', () => {
|
||||
const onSaveAsExplore = jest.fn();
|
||||
render(
|
||||
<SaveDatasetActionButton
|
||||
setShowSave={() => true}
|
||||
onSaveAsExplore={onSaveAsExplore}
|
||||
canSaveDataset={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Saving the query needs no results.
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the query did not run successfully', async () => {
|
||||
render(
|
||||
<SaveDatasetActionButton
|
||||
|
||||
@@ -19,14 +19,12 @@
|
||||
import { act, type ComponentProps } from 'react';
|
||||
import {
|
||||
cleanup,
|
||||
createStore,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import reducerIndex from 'spec/helpers/reducerIndex';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
|
||||
@@ -65,12 +63,6 @@ beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// In-body restores are skipped when an assertion throws, leaking a
|
||||
// configured spy into later tests.
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Mock createDatasource to return a thunk that resolves with the dataset's
|
||||
// new id. The test's mock store includes redux-thunk middleware (from RTK's
|
||||
// getDefaultMiddleware), so dispatch(createDatasource(...)) properly unwraps
|
||||
@@ -526,39 +518,6 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('surfaces the error and keeps the modal open when saving fails', async () => {
|
||||
// The chart-payload step's toast was built but never dispatched, so a
|
||||
// failure there was silent.
|
||||
const postFormData = jest.spyOn(
|
||||
require('src/explore/exploreUtils/formData'),
|
||||
'postFormData',
|
||||
);
|
||||
postFormData.mockRejectedValue(new Error('Boom'));
|
||||
const onHide = jest.fn();
|
||||
const store = createStore({ user }, reducerIndex);
|
||||
|
||||
render(<SaveDatasetModal {...mockedProps} onHide={onHide} />, { store });
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue(/unimportant/i), {
|
||||
target: { value: 'my dataset' },
|
||||
});
|
||||
userEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
// `createStore` builds its reducer map at runtime, so state isn't typed.
|
||||
const toasts = () =>
|
||||
(
|
||||
store.getState() as unknown as {
|
||||
messageToasts: { toastType: string }[];
|
||||
}
|
||||
).messageToasts;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toasts()).toHaveLength(1);
|
||||
});
|
||||
expect(toasts()[0].toastType).toBe('DANGER_TOAST');
|
||||
expect(onHide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clearDatasetCache is imported and available', () => {
|
||||
const { clearDatasetCache } = require('src/utils/cachedSupersetGet');
|
||||
|
||||
|
||||
@@ -61,9 +61,6 @@ import type Subject from 'src/types/Subject';
|
||||
import { openInNewTab, redirect } from 'src/utils/navigationUtils';
|
||||
import { mapSubjectValuesToIds } from 'src/features/subjects/SubjectPicker';
|
||||
|
||||
// Derived so it can't drift from what `getClientErrorObject` accepts.
|
||||
type SaveErrorSource = Parameters<typeof getClientErrorObject>[0];
|
||||
|
||||
interface QueryDatabase {
|
||||
id?: number;
|
||||
}
|
||||
@@ -394,18 +391,9 @@ export const SaveDatasetModal = ({
|
||||
setDatasetName(getDefaultDatasetName());
|
||||
onHide();
|
||||
})
|
||||
.catch((error?: SaveErrorSource) => {
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
// `createDatasource` already toasted the server's message and rejects
|
||||
// with nothing; only the chart-payload step needs its own.
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
getClientErrorObject(error).then(e =>
|
||||
dispatch(
|
||||
addDangerToast(e.error || t('An error occurred saving dataset')),
|
||||
),
|
||||
);
|
||||
addDangerToast(t('An error occurred saving dataset'));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -27,8 +27,6 @@ import {
|
||||
import SaveQuery from 'src/SqlLab/components/SaveQuery';
|
||||
import { initialState, databases } from 'src/SqlLab/fixtures';
|
||||
|
||||
const RESULT_COLUMNS = [{ column_name: 'col', type: 'STRING' }];
|
||||
|
||||
const mockedProps = {
|
||||
queryEditorId: '123',
|
||||
animation: false,
|
||||
@@ -37,6 +35,7 @@ const mockedProps = {
|
||||
onSave: () => {},
|
||||
saveQueryWarning: null,
|
||||
columns: [],
|
||||
canSaveDataset: true,
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
@@ -61,31 +60,8 @@ const splitSaveBtnProps = {
|
||||
...mockedProps.database,
|
||||
allows_virtual_table_explore: true,
|
||||
},
|
||||
columns: RESULT_COLUMNS,
|
||||
};
|
||||
|
||||
const EDITOR_SQL = 'SELECT * FROM t';
|
||||
|
||||
const stateWithLatestQuery = ({
|
||||
id,
|
||||
state,
|
||||
sql = EDITOR_SQL,
|
||||
}: {
|
||||
id: string;
|
||||
state: string;
|
||||
sql?: string;
|
||||
}) => ({
|
||||
...mockState,
|
||||
sqlLab: {
|
||||
...mockState.sqlLab,
|
||||
queryEditors: mockState.sqlLab.queryEditors.map(qe => ({
|
||||
...qe,
|
||||
latestQueryId: id,
|
||||
})),
|
||||
queries: { [id]: { id, state, sql } },
|
||||
},
|
||||
});
|
||||
|
||||
const middlewares = [thunk];
|
||||
const mockStore = configureStore(middlewares);
|
||||
|
||||
@@ -121,71 +97,6 @@ describe('SavedQuery', () => {
|
||||
expect(saveBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" until the query has run successfully', () => {
|
||||
// Without a successful run the save can only fail server-side.
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'failed' })),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
// Saving the query itself is unaffected.
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" when no query has been run at all', () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(mockState),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" when the SQL changed after a successful run', () => {
|
||||
// The run succeeded, but not for what is in the editor now -- and it is
|
||||
// the editor's SQL that gets saved.
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(
|
||||
stateWithLatestQuery({
|
||||
id: 'qid-1',
|
||||
state: 'success',
|
||||
sql: 'SELECT 1 AS ran_earlier',
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" when the successful query returned no columns', () => {
|
||||
// e.g. a DDL/DML statement -- there is nothing to introspect into a dataset.
|
||||
render(<SaveQuery {...splitSaveBtnProps} columns={[]} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('enables "Save dataset" once the query has succeeded', () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: /save dataset/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('renders a save query modal when user clicks save button', () => {
|
||||
render(<SaveQuery {...mockedProps} />, {
|
||||
useRedux: true,
|
||||
@@ -323,7 +234,7 @@ describe('SavedQuery', () => {
|
||||
test('renders a save dataset modal when user clicks "save dataset" menu item', async () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
store: mockStore(mockState),
|
||||
});
|
||||
|
||||
const saveDatasetMenuItem = await screen.findByLabelText(/save dataset/i);
|
||||
@@ -337,7 +248,7 @@ describe('SavedQuery', () => {
|
||||
test('renders the save dataset modal UI', async () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
store: mockStore(mockState),
|
||||
});
|
||||
const saveDatasetMenuItem = await screen.findByLabelText(/save dataset/i);
|
||||
userEvent.click(saveDatasetMenuItem);
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useEffect, useMemo, ChangeEvent } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Query, QueryState } from '@superset-ui/core';
|
||||
import type { DatabaseObject } from 'src/features/databases/types';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
@@ -39,7 +37,7 @@ import {
|
||||
} from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
|
||||
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
|
||||
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
|
||||
import { QueryEditor } from 'src/SqlLab/types';
|
||||
import useLogAction from 'src/logger/useLogAction';
|
||||
import {
|
||||
LOG_ACTIONS_SQLLAB_CREATE_CHART,
|
||||
@@ -54,6 +52,7 @@ interface SaveQueryProps {
|
||||
onUpdate: (arg0: QueryPayload, id: string) => void;
|
||||
saveQueryWarning: string | null;
|
||||
database: Partial<DatabaseObject> | undefined;
|
||||
canSaveDataset: boolean;
|
||||
}
|
||||
|
||||
export type QueryPayload = {
|
||||
@@ -83,6 +82,7 @@ const SaveQuery = ({
|
||||
saveQueryWarning,
|
||||
database,
|
||||
columns,
|
||||
canSaveDataset,
|
||||
}: SaveQueryProps) => {
|
||||
const queryEditor = useQueryEditor(queryEditorId, [
|
||||
'autorun',
|
||||
@@ -113,17 +113,6 @@ const SaveQuery = ({
|
||||
const [label, setLabel] = useState<string>(defaultLabel);
|
||||
const [showSave, setShowSave] = useState<boolean>(false);
|
||||
const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
|
||||
// Saving a dataset runs the SQL to introspect columns, so it needs a
|
||||
// successful run of the SQL being saved that produced at least one column
|
||||
// -- editing after a run invalidates it, and running a selection only
|
||||
// validates that selection.
|
||||
const latestQuery = useSelector<SqlLabRootState, Query | undefined>(
|
||||
({ sqlLab }) => sqlLab.queries[queryEditor.latestQueryId || ''],
|
||||
);
|
||||
const canSaveDataset =
|
||||
latestQuery?.state === QueryState.Success &&
|
||||
latestQuery.sql === queryEditor.sql &&
|
||||
columns.length > 0;
|
||||
const isSaved = !!query.remoteId;
|
||||
const isLabelEmpty = label.trim().length === 0;
|
||||
const canExploreDatabase = !!database?.allows_virtual_table_explore;
|
||||
|
||||
@@ -356,10 +356,7 @@ describe('SqlEditor', () => {
|
||||
);
|
||||
|
||||
test('enables the save dataset button when the latest query succeeded', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({
|
||||
state: QueryState.Success,
|
||||
sql: mockedProps.queryEditor.sql,
|
||||
});
|
||||
const { findByRole } = setupWithLatestQuery({ state: QueryState.Success });
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeEnabled();
|
||||
});
|
||||
|
||||
|
||||
@@ -868,6 +868,7 @@ const SqlEditor: FC<Props> = ({
|
||||
}
|
||||
saveQueryWarning={saveQueryWarning}
|
||||
database={database}
|
||||
canSaveDataset={successful && resultColumns.length > 0}
|
||||
/>
|
||||
<ShareSqlLabQuery queryEditorId={queryEditor.id} />
|
||||
</>
|
||||
|
||||
+49
-19
@@ -196,6 +196,54 @@ interface DatasourceObject {
|
||||
folders?: DatasourceFolder[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift the certification and warning fields a metric keeps inside its `extra`
|
||||
* JSON blob onto the metric itself, which is the shape the editor's fields bind
|
||||
* to.
|
||||
*
|
||||
* Two entry points feed the editor two different metric shapes: the dataset
|
||||
* list hands over the API payload, where `extra` is still a JSON string, while
|
||||
* Explore hands over its bootstrap payload, where `SqlMetric.data` has already
|
||||
* flattened `extra` into `warning_markdown` and dropped the raw string. The
|
||||
* parsed blob is therefore only authoritative when `extra` is actually present;
|
||||
* otherwise the already-flattened value stands, instead of being reset to an
|
||||
* empty field.
|
||||
*
|
||||
* A malformed `extra` string is treated the same as an absent one (falls
|
||||
* through to the already-flattened value) rather than throwing, mirroring
|
||||
* the backend's own tolerance for bad `extra` JSON in
|
||||
* `CertificationMixin.get_extra_dict()`.
|
||||
*/
|
||||
export function hydrateMetricExtra(metric: Metric): Metric {
|
||||
const {
|
||||
certified_by: certifiedByMetric,
|
||||
certification_details: certificationDetails,
|
||||
} = metric;
|
||||
let parsedExtra;
|
||||
if (metric.extra) {
|
||||
try {
|
||||
parsedExtra = JSON.parse(metric.extra) || {};
|
||||
} catch {
|
||||
parsedExtra = undefined;
|
||||
}
|
||||
}
|
||||
const {
|
||||
certification: {
|
||||
details = undefined,
|
||||
certified_by: certifiedBy = undefined,
|
||||
} = {},
|
||||
} = parsedExtra || {};
|
||||
const warningMarkdown = parsedExtra
|
||||
? parsedExtra.warning_markdown
|
||||
: metric.warning_markdown;
|
||||
return {
|
||||
...metric,
|
||||
certification_details: certificationDetails || details,
|
||||
warning_markdown: warningMarkdown || '',
|
||||
certified_by: certifiedBy || certifiedByMetric,
|
||||
};
|
||||
}
|
||||
|
||||
interface DatasourceEditorOwnProps {
|
||||
datasource: DatasourceObject;
|
||||
onChange?: (datasource: DatasourceObject, errors: string[]) => void;
|
||||
@@ -852,25 +900,7 @@ function DatasourceEditor({
|
||||
const [datasource, setDatasource] = useState<DatasourceObject>(() => ({
|
||||
...propsDatasource,
|
||||
editors: normalizeSubjectsToPickerValues(propsDatasource.editors || []),
|
||||
metrics: propsDatasource.metrics?.map(metric => {
|
||||
const {
|
||||
certified_by: certifiedByMetric,
|
||||
certification_details: certificationDetails,
|
||||
} = metric;
|
||||
const {
|
||||
certification: {
|
||||
details = undefined,
|
||||
certified_by: certifiedBy = undefined,
|
||||
} = {},
|
||||
warning_markdown: warningMarkdown,
|
||||
} = JSON.parse(metric.extra || '{}') || {};
|
||||
return {
|
||||
...metric,
|
||||
certification_details: certificationDetails || details,
|
||||
warning_markdown: warningMarkdown || metric.warning_markdown || '',
|
||||
certified_by: certifiedBy || certifiedByMetric,
|
||||
};
|
||||
}),
|
||||
metrics: propsDatasource.metrics?.map(hydrateMetricExtra),
|
||||
}));
|
||||
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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 { hydrateMetricExtra } from '../DatasourceEditor';
|
||||
|
||||
const metric = { metric_name: 'sum__num', expression: 'SUM(num)' };
|
||||
|
||||
test('lifts the warning and certification out of the extra JSON string', () => {
|
||||
expect(
|
||||
hydrateMetricExtra({
|
||||
...metric,
|
||||
extra: JSON.stringify({
|
||||
warning_markdown: 'Handle with care',
|
||||
certification: { certified_by: 'Data team', details: 'Reviewed' },
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
warning_markdown: 'Handle with care',
|
||||
certified_by: 'Data team',
|
||||
certification_details: 'Reviewed',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps an already-flattened warning when the metric carries no extra (#42704)', () => {
|
||||
// Explore's bootstrap payload flattens `extra` into `warning_markdown` and
|
||||
// drops the raw string, so the flattened value is all there is to go on.
|
||||
expect(
|
||||
hydrateMetricExtra({ ...metric, warning_markdown: 'Handle with care' })
|
||||
.warning_markdown,
|
||||
).toBe('Handle with care');
|
||||
});
|
||||
|
||||
test('lets an empty warning in extra clear the flattened value', () => {
|
||||
expect(
|
||||
hydrateMetricExtra({
|
||||
...metric,
|
||||
warning_markdown: 'stale',
|
||||
extra: '{}',
|
||||
}).warning_markdown,
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
test('normalizes a missing warning to an empty string', () => {
|
||||
expect(hydrateMetricExtra(metric).warning_markdown).toBe('');
|
||||
});
|
||||
|
||||
test('resolves certification conflicts between the metric and its extra blob', () => {
|
||||
expect(
|
||||
hydrateMetricExtra({
|
||||
...metric,
|
||||
certified_by: 'Analytics',
|
||||
certification_details: 'Owned by Analytics',
|
||||
extra: JSON.stringify({
|
||||
certification: { certified_by: 'Data team', details: 'Reviewed' },
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
// extra wins for the certifier, while the metric's own details field wins
|
||||
// for the description — the certification form writes both back into extra
|
||||
// on save, so the two settle on the same source afterwards
|
||||
certified_by: 'Data team',
|
||||
certification_details: 'Owned by Analytics',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not throw on malformed extra, falling back like an absent extra', () => {
|
||||
expect(() =>
|
||||
hydrateMetricExtra({
|
||||
...metric,
|
||||
warning_markdown: 'Handle with care',
|
||||
extra: '{not valid json',
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(
|
||||
hydrateMetricExtra({
|
||||
...metric,
|
||||
warning_markdown: 'Handle with care',
|
||||
extra: '{not valid json',
|
||||
}).warning_markdown,
|
||||
).toBe('Handle with care');
|
||||
});
|
||||
@@ -17,10 +17,20 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { VizType } from '@superset-ui/core';
|
||||
import { isFeatureEnabled, VizType } from '@superset-ui/core';
|
||||
import { HYDRATE_CHART_NORMALIZATION } from 'src/features/versionHistory/reducer';
|
||||
import { hydrateExplore, HYDRATE_EXPLORE } from './hydrateExplore';
|
||||
import { exploreInitialData } from '../fixtures';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
|
||||
beforeEach(() => mockedIsFeatureEnabled.mockReturnValue(false));
|
||||
|
||||
afterEach(() => {
|
||||
window.history.pushState({}, '', '/');
|
||||
});
|
||||
@@ -343,3 +353,67 @@ test('extracts currency formats from metrics in dataset', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('seeds only guarded matching-input hydration transitions', () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn(() => ({
|
||||
user: {},
|
||||
charts: {},
|
||||
datasources: {},
|
||||
common: { conf: { DEFAULT_TIME_FILTER: 'Last year' } },
|
||||
explore: {},
|
||||
}));
|
||||
const persisted = {
|
||||
...exploreInitialData.form_data,
|
||||
};
|
||||
delete persisted.time_range;
|
||||
const initialData = {
|
||||
...exploreInitialData,
|
||||
form_data: { ...persisted },
|
||||
slice: {
|
||||
...exploreInitialData.slice!,
|
||||
form_data: { ...persisted },
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error focused hydration fixture
|
||||
hydrateExplore(initialData)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: HYDRATE_CHART_NORMALIZATION,
|
||||
tracking: expect.objectContaining({
|
||||
chartId: 371,
|
||||
transitions: expect.objectContaining({
|
||||
time_range: {
|
||||
control: 'time_range',
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: 'Last year',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not seed normalization metadata for dashboard overrides', () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
window.history.pushState({}, '', '/explore/?dashboard_id=12');
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn(() => ({
|
||||
user: {},
|
||||
charts: {},
|
||||
datasources: {},
|
||||
common: {},
|
||||
explore: {},
|
||||
}));
|
||||
|
||||
// @ts-expect-error focused hydration fixture
|
||||
hydrateExplore(exploreInitialData)(dispatch, getState);
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: HYDRATE_CHART_NORMALIZATION }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -49,6 +49,10 @@ import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { nanoid } from 'nanoid';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import { hydrateChartNormalization } from 'src/features/versionHistory/reducer';
|
||||
import { automaticNormalizationTransitions } from 'src/features/versionHistory/normalization';
|
||||
|
||||
enum ColorSchemeType {
|
||||
CATEGORICAL = 'CATEGORICAL',
|
||||
@@ -78,6 +82,8 @@ export const hydrateExplore =
|
||||
const fallbackSlice = sliceId ? sliceEntities?.slices?.[sliceId] : null;
|
||||
const initialSlice = slice ?? fallbackSlice;
|
||||
const initialFormData = form_data ?? initialSlice?.form_data;
|
||||
const persistedFormData = cloneDeep(initialSlice?.form_data ?? {});
|
||||
const preHydrationFormData = cloneDeep(initialFormData ?? {});
|
||||
const isCachedFormData = getUrlParam(URL_PARAMS.formDataKey) !== null;
|
||||
const [primarySliceNameSource, fallbackSliceNameSource] = isCachedFormData
|
||||
? [initialFormData, initialSlice]
|
||||
@@ -213,6 +219,10 @@ export const hydrateExplore =
|
||||
exploreState,
|
||||
);
|
||||
});
|
||||
const hydratedFormData = {
|
||||
...initialFormData,
|
||||
...getFormDataFromControls(exploreState.controls),
|
||||
};
|
||||
const sliceFormData = initialSlice
|
||||
? getFormDataFromControls(initialControls)
|
||||
: null;
|
||||
@@ -233,7 +243,7 @@ export const hydrateExplore =
|
||||
lastRendered: 0,
|
||||
};
|
||||
|
||||
return dispatch({
|
||||
const result = dispatch({
|
||||
type: HYDRATE_EXPLORE,
|
||||
data: {
|
||||
charts: {
|
||||
@@ -253,6 +263,28 @@ export const hydrateExplore =
|
||||
dataMask,
|
||||
},
|
||||
});
|
||||
if (
|
||||
isFeatureEnabled(FeatureFlag.VersionHistory) &&
|
||||
initialSlice?.slice_id &&
|
||||
!isCachedFormData &&
|
||||
!dashboardId &&
|
||||
getUrlParam(URL_PARAMS.vizType) === null
|
||||
) {
|
||||
dispatch(
|
||||
hydrateChartNormalization({
|
||||
chartId: initialSlice.slice_id,
|
||||
hydrationSessionId: nanoid(),
|
||||
transitions: automaticNormalizationTransitions(
|
||||
persistedFormData,
|
||||
preHydrationFormData,
|
||||
hydratedFormData,
|
||||
),
|
||||
invalidatedControls: {},
|
||||
saveAttemptId: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export type HydrateExplore = {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Dispatch } from 'redux';
|
||||
import { ADD_TOAST } from 'src/components/MessageToasts/actions';
|
||||
import {
|
||||
DatasourceType,
|
||||
isFeatureEnabled,
|
||||
QueryFormData,
|
||||
SimpleAdhocFilter,
|
||||
VizType,
|
||||
@@ -37,6 +38,13 @@ import {
|
||||
} from './saveModalActions';
|
||||
import { Operators } from '../constants';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
|
||||
// Define test constants and mock data using imported types
|
||||
const sliceId = 10;
|
||||
const sliceName = 'New chart';
|
||||
@@ -92,17 +100,159 @@ const sliceResponsePayload: Partial<PayloadSlice> = {
|
||||
};
|
||||
|
||||
const sampleError = new Error('sampleError');
|
||||
const updateSliceEndpoint = `glob:*/api/v1/chart/${sliceId}`;
|
||||
|
||||
jest.mock('../exploreUtils', () => ({
|
||||
buildV1ChartDataPayload: jest.fn(() => queryContext),
|
||||
}));
|
||||
|
||||
beforeEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
beforeEach(() => {
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
mockedIsFeatureEnabled.mockReturnValue(false);
|
||||
});
|
||||
|
||||
test('existing-chart overwrite sends only still-matching normalization metadata', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
});
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({
|
||||
explore: {
|
||||
form_data: {
|
||||
datasource: `${datasourceId}__${datasourceType}`,
|
||||
viz_type: vizType,
|
||||
row_limit: 10000,
|
||||
show_legend: true,
|
||||
object_control: { a: 1, b: 2 },
|
||||
},
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: sliceId,
|
||||
hydrationSessionId: 'hydration-a',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: { show_legend: true as const },
|
||||
transitions: {
|
||||
row_limit: {
|
||||
control: 'row_limit',
|
||||
from_present: true as const,
|
||||
from_value: null,
|
||||
to_present: true as const,
|
||||
to_value: 10000,
|
||||
},
|
||||
show_legend: {
|
||||
control: 'show_legend',
|
||||
from_present: false as const,
|
||||
to_present: true as const,
|
||||
to_value: true,
|
||||
},
|
||||
object_control: {
|
||||
control: 'object_control',
|
||||
from_present: false as const,
|
||||
to_present: true as const,
|
||||
to_value: { b: 2, a: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateSlice(
|
||||
{ ...sliceResponsePayload, slice_id: sliceId } as never,
|
||||
sliceName,
|
||||
[],
|
||||
)(dispatch, getState);
|
||||
|
||||
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
|
||||
const body = JSON.parse(request?.options.body as string);
|
||||
expect(body.normalization_changes).toEqual([
|
||||
{
|
||||
control: 'row_limit',
|
||||
from_present: true,
|
||||
from_value: null,
|
||||
to_present: true,
|
||||
to_value: 10000,
|
||||
},
|
||||
{
|
||||
control: 'object_control',
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: { b: 2, a: 1 },
|
||||
},
|
||||
]);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'BEGIN_CHART_NORMALIZATION_SAVE' }),
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'COMPLETE_CHART_NORMALIZATION_SAVE' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('matches normalization metadata against finalized payload filters', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
});
|
||||
const extraTemporalFilter = {
|
||||
expressionType: 'SIMPLE',
|
||||
clause: 'WHERE',
|
||||
subject: 'ds',
|
||||
operator: Operators.TemporalRange,
|
||||
comparator: '',
|
||||
isExtra: true,
|
||||
} as SimpleAdhocFilter;
|
||||
const savedTemporalFilter = {
|
||||
...extraTemporalFilter,
|
||||
comparator: 'No filter',
|
||||
isExtra: false,
|
||||
};
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({
|
||||
explore: {
|
||||
form_data: {
|
||||
datasource: `${datasourceId}__${datasourceType}`,
|
||||
viz_type: vizType,
|
||||
adhoc_filters: [extraTemporalFilter],
|
||||
},
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: sliceId,
|
||||
hydrationSessionId: 'hydration-a',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: {},
|
||||
transitions: {
|
||||
adhoc_filters: {
|
||||
control: 'adhoc_filters',
|
||||
from_present: false as const,
|
||||
to_present: true as const,
|
||||
to_value: [savedTemporalFilter],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateSlice(
|
||||
{ ...sliceResponsePayload, slice_id: sliceId } as never,
|
||||
sliceName,
|
||||
[],
|
||||
)(dispatch, getState);
|
||||
|
||||
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
|
||||
const body = JSON.parse(request?.options.body as string);
|
||||
expect(body.normalization_changes).toEqual([
|
||||
expect.objectContaining({
|
||||
control: 'adhoc_filters',
|
||||
to_value: [savedTemporalFilter],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests updateSlice action
|
||||
*/
|
||||
const updateSliceEndpoint = `glob:*/api/v1/chart/${sliceId}`;
|
||||
test('updateSlice handles success', async () => {
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
@@ -734,3 +884,97 @@ describe('getSlicePayload', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('existing-chart overwrite covers stash-removed keys as drop transitions', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
});
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({
|
||||
explore: {
|
||||
// The stash removed order_desc from active form data...
|
||||
form_data: {
|
||||
datasource: `${datasourceId}__${datasourceType}`,
|
||||
viz_type: vizType,
|
||||
row_limit: 10000,
|
||||
},
|
||||
// ...and holds it with the value it had when hidden.
|
||||
hiddenFormData: { order_desc: true },
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: sliceId,
|
||||
hydrationSessionId: 'hydration-drop',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: {},
|
||||
transitions: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateSlice(
|
||||
{
|
||||
...sliceResponsePayload,
|
||||
slice_id: sliceId,
|
||||
// Persisted params carry the key the stash removed, same value.
|
||||
form_data: { ...formData, order_desc: true },
|
||||
} as never,
|
||||
sliceName,
|
||||
[],
|
||||
)(dispatch, getState);
|
||||
|
||||
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
|
||||
const body = JSON.parse(request?.options.body as string);
|
||||
expect(body.normalization_changes).toEqual([
|
||||
{
|
||||
control: 'order_desc',
|
||||
from_present: true,
|
||||
from_value: true,
|
||||
to_present: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('a stashed value the user changed before hiding is not covered', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
});
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({
|
||||
explore: {
|
||||
form_data: {
|
||||
datasource: `${datasourceId}__${datasourceType}`,
|
||||
viz_type: vizType,
|
||||
row_limit: 10000,
|
||||
},
|
||||
// Stash holds a USER-edited value; persisted differs, so the removal
|
||||
// stays recorded.
|
||||
hiddenFormData: { order_desc: false },
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: sliceId,
|
||||
hydrationSessionId: 'hydration-drop-2',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: {},
|
||||
transitions: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateSlice(
|
||||
{
|
||||
...sliceResponsePayload,
|
||||
slice_id: sliceId,
|
||||
form_data: { ...formData, order_desc: true },
|
||||
} as never,
|
||||
sliceName,
|
||||
[],
|
||||
)(dispatch, getState);
|
||||
|
||||
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
|
||||
const body = JSON.parse(request?.options.body as string);
|
||||
expect(body.normalization_changes).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ import { Dispatch } from 'redux';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
DatasourceType,
|
||||
FeatureFlag,
|
||||
isFeatureEnabled,
|
||||
type QueryFormData,
|
||||
SimpleAdhocFilter,
|
||||
SupersetClient,
|
||||
@@ -30,11 +32,25 @@ import { isEmpty } from 'lodash-es';
|
||||
import { Slice } from 'src/dashboard/types';
|
||||
import { Operators } from '../constants';
|
||||
import { buildV1ChartDataPayload } from '../exploreUtils';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
beginChartNormalizationSave,
|
||||
completeChartNormalizationSave,
|
||||
} from 'src/features/versionHistory/reducer';
|
||||
import type {
|
||||
AutomaticNormalizationTransitions,
|
||||
ChartNormalizationTrackingState,
|
||||
} from 'src/features/versionHistory/types';
|
||||
import {
|
||||
matchingAutomaticNormalizationTransitions,
|
||||
stashDropNormalizationTransitions,
|
||||
} from 'src/features/versionHistory/normalization';
|
||||
|
||||
export interface PayloadSlice extends Slice {
|
||||
params: string;
|
||||
dashboards: number[];
|
||||
query_context: string;
|
||||
normalization_changes?: AutomaticNormalizationTransitions[string][];
|
||||
}
|
||||
const ADHOC_FILTER_REGEX = /^adhoc_filters/;
|
||||
|
||||
@@ -233,21 +249,84 @@ export const updateSlice =
|
||||
new?: boolean;
|
||||
},
|
||||
) =>
|
||||
async (dispatch: Dispatch, getState: () => Partial<QueryFormData>) => {
|
||||
async (
|
||||
dispatch: Dispatch,
|
||||
getState: () => Partial<QueryFormData> & {
|
||||
versionHistory?: {
|
||||
chartNormalization?: ChartNormalizationTrackingState | null;
|
||||
};
|
||||
explore?: {
|
||||
form_data?: QueryFormData;
|
||||
hiddenFormData?: Record<string, unknown>;
|
||||
};
|
||||
},
|
||||
) => {
|
||||
const { slice_id: sliceId, editors, form_data: formDataFromSlice } = slice;
|
||||
const formData = getState().explore?.form_data;
|
||||
const initialState = getState();
|
||||
const formData = JSON.parse(
|
||||
JSON.stringify(initialState.explore?.form_data ?? {}),
|
||||
) as QueryFormData;
|
||||
const tracking = initialState.versionHistory?.chartNormalization;
|
||||
const saveAttemptId = nanoid();
|
||||
const shouldAttachNormalization =
|
||||
isFeatureEnabled(FeatureFlag.VersionHistory) &&
|
||||
tracking?.chartId === sliceId;
|
||||
if (shouldAttachNormalization) {
|
||||
dispatch(
|
||||
beginChartNormalizationSave(
|
||||
sliceId,
|
||||
tracking.hydrationSessionId,
|
||||
saveAttemptId,
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
const payload = await getSlicePayload(
|
||||
sliceName,
|
||||
formData,
|
||||
dashboards,
|
||||
editors as [],
|
||||
formDataFromSlice,
|
||||
);
|
||||
const savedFormData = JSON.parse(payload.params ?? '{}') as QueryFormData;
|
||||
// Hydration-time transitions that still hold, plus save-time drops of
|
||||
// keys the stash removed (mutually exclusive per control: a surviving
|
||||
// hydration transition implies the key is present in the payload, a
|
||||
// stash drop implies it is absent).
|
||||
const matchingTransitions = shouldAttachNormalization
|
||||
? {
|
||||
...matchingAutomaticNormalizationTransitions(
|
||||
tracking,
|
||||
savedFormData,
|
||||
),
|
||||
...stashDropNormalizationTransitions(
|
||||
(formDataFromSlice ?? {}) as Record<string, unknown>,
|
||||
initialState.explore?.hiddenFormData,
|
||||
savedFormData,
|
||||
),
|
||||
}
|
||||
: {};
|
||||
if (
|
||||
shouldAttachNormalization &&
|
||||
Object.keys(matchingTransitions).length
|
||||
) {
|
||||
payload.normalization_changes = Object.values(matchingTransitions);
|
||||
}
|
||||
const response = await SupersetClient.put({
|
||||
endpoint: `/api/v1/chart/${sliceId}`,
|
||||
jsonPayload: await getSlicePayload(
|
||||
sliceName,
|
||||
formData,
|
||||
dashboards,
|
||||
editors as [],
|
||||
formDataFromSlice,
|
||||
),
|
||||
jsonPayload: payload,
|
||||
});
|
||||
|
||||
if (shouldAttachNormalization) {
|
||||
dispatch(
|
||||
completeChartNormalizationSave(
|
||||
sliceId,
|
||||
tracking.hydrationSessionId,
|
||||
saveAttemptId,
|
||||
{},
|
||||
),
|
||||
);
|
||||
}
|
||||
dispatch(saveSliceSuccess(response.json));
|
||||
addToasts(false, sliceName, addedToDashboard).map(dispatch);
|
||||
return response.json;
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ export default function DndAdhocFilterOption({
|
||||
<OptionWrapper
|
||||
key={index}
|
||||
index={index}
|
||||
label={actualTimeRange ?? adhocFilter.getDefaultLabel()}
|
||||
label={actualTimeRange ?? adhocFilter.getDefaultLabel(options)}
|
||||
tooltipTitle={title ?? adhocFilter.getTooltipTitle()}
|
||||
clickClose={onClickClose}
|
||||
onShiftOptions={onShiftOptions}
|
||||
|
||||
+30
-1
@@ -43,7 +43,7 @@ import {
|
||||
DndFilterSelectProps,
|
||||
} from 'src/explore/components/controls/DndColumnSelectControl/DndFilterSelect';
|
||||
import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants';
|
||||
import { ExpressionTypes } from '../FilterControl/types';
|
||||
import { Clauses, ExpressionTypes } from '../FilterControl/types';
|
||||
import { DndItemType } from '../../DndItemType';
|
||||
import { Datasource } from '../../../types';
|
||||
import {
|
||||
@@ -137,6 +137,35 @@ test('renders with value', async () => {
|
||||
expect(await screen.findByText('COUNT(*)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the pill using the column verbose_name when one is set', async () => {
|
||||
const value = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'num',
|
||||
operator: '>',
|
||||
comparator: '500',
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
render(
|
||||
setup({
|
||||
value,
|
||||
columns: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'BIGINT',
|
||||
type_generic: GenericDataType.Numeric,
|
||||
column_name: 'num',
|
||||
verbose_name: 'total_count',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
useDndKit: true,
|
||||
store,
|
||||
},
|
||||
);
|
||||
expect(await screen.findByText('total_count > 500')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders options with saved metric', async () => {
|
||||
render(
|
||||
setup({
|
||||
|
||||
+28
@@ -370,4 +370,32 @@ describe('AdhocFilter', () => {
|
||||
});
|
||||
expect(adhocFilter.getDefaultLabel()).toBe('');
|
||||
});
|
||||
test('uses the column verbose_name in the label when one is given', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'num',
|
||||
operator: '>',
|
||||
comparator: '500',
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(
|
||||
adhocFilter.getDefaultLabel([
|
||||
{ column_name: 'num', verbose_name: 'total_count' },
|
||||
]),
|
||||
).toBe('total_count > 500');
|
||||
});
|
||||
test('falls back to the column_name when no verbose_name is set', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'num',
|
||||
operator: '>',
|
||||
comparator: '500',
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(
|
||||
adhocFilter.getDefaultLabel([{ column_name: 'num', verbose_name: '' }]),
|
||||
).toBe('num > 500');
|
||||
expect(adhocFilter.getDefaultLabel([])).toBe('num > 500');
|
||||
expect(adhocFilter.getDefaultLabel()).toBe('num > 500');
|
||||
});
|
||||
});
|
||||
|
||||
+5
-5
@@ -23,7 +23,7 @@ import {
|
||||
OPERATOR_ENUM_TO_OPERATOR_TYPE,
|
||||
Operators,
|
||||
} from 'src/explore/constants';
|
||||
import { translateToSql } from '../utils/translateToSQL';
|
||||
import { translateToSql, VerboseColumn } from '../utils/translateToSQL';
|
||||
import { Clauses, ExpressionTypes } from '../types';
|
||||
|
||||
const CUSTOM_OPERATIONS = [...CUSTOM_OPERATORS].map(
|
||||
@@ -193,8 +193,8 @@ export default class AdhocFilter {
|
||||
);
|
||||
}
|
||||
|
||||
getDefaultLabel(): string {
|
||||
const label = this.translateToSql();
|
||||
getDefaultLabel(columns?: VerboseColumn[]): string {
|
||||
const label = this.translateToSql({ columns });
|
||||
return label.length < 43 ? label : `${label.substring(0, 40)}...`;
|
||||
}
|
||||
|
||||
@@ -202,8 +202,8 @@ export default class AdhocFilter {
|
||||
return this.translateToSql();
|
||||
}
|
||||
|
||||
translateToSql(): string {
|
||||
return translateToSql(this as unknown as CoreAdhocFilter);
|
||||
translateToSql(params: { columns?: VerboseColumn[] } = {}): string {
|
||||
return translateToSql(this as unknown as CoreAdhocFilter, params);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+37
@@ -23,6 +23,7 @@ import {
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import thunk from 'redux-thunk';
|
||||
import configureStore from 'redux-mock-store';
|
||||
@@ -914,3 +915,39 @@ test('dropdown should remain open when clicked after filter is configured', asyn
|
||||
|
||||
expect(operatorDropdown).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
test('filters the subject select by column verbose_name as well as column_name', async () => {
|
||||
setup({
|
||||
options: [
|
||||
{
|
||||
type: 'BIGINT',
|
||||
column_name: 'num',
|
||||
verbose_name: 'total_count',
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
type: 'VARCHAR(255)',
|
||||
column_name: 'name',
|
||||
verbose_name: 'Full Name',
|
||||
id: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const combobox = screen.getByRole('combobox', { name: 'Select subject' });
|
||||
userEvent.click(combobox);
|
||||
|
||||
await userEvent.type(combobox, 'total');
|
||||
|
||||
const dropdown = document.querySelector(
|
||||
'.ant-select-dropdown-list',
|
||||
) as HTMLElement;
|
||||
expect(within(dropdown).getByText('total_count')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Full Name')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, 'num');
|
||||
|
||||
expect(within(dropdown).getByText('total_count')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Full Name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
+4
@@ -639,7 +639,11 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
('optionName' in column && column.optionName) ||
|
||||
undefined,
|
||||
label: renderSubjectOptionLabel(column),
|
||||
column_name: 'column_name' in column ? column.column_name : undefined,
|
||||
verbose_name:
|
||||
'verbose_name' in column ? column.verbose_name : undefined,
|
||||
}))}
|
||||
optionFilterProps={['column_name', 'verbose_name']}
|
||||
{...subjectSelectProps}
|
||||
/>
|
||||
);
|
||||
|
||||
+18
@@ -71,6 +71,24 @@ test('should render the control label', async () => {
|
||||
expect(await screen.findByText('value > 10')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the control label using the column verbose_name when one is set', async () => {
|
||||
render(
|
||||
setup({
|
||||
...mockedProps,
|
||||
options: [
|
||||
{
|
||||
type: 'DOUBLE',
|
||||
column_name: 'value',
|
||||
verbose_name: 'total_count',
|
||||
id: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ useDnd: true, useRedux: true },
|
||||
);
|
||||
expect(await screen.findByText('total_count > 10')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the remove button', async () => {
|
||||
render(setup(mockedProps), { useDnd: true, useRedux: true });
|
||||
const removeBtn = await screen.findByTestId('remove-control-button');
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ export default function AdhocFilterOption({
|
||||
partitionColumn={partitionColumn ?? undefined}
|
||||
>
|
||||
<OptionControlLabel
|
||||
label={actualTimeRange ?? adhocFilter.getDefaultLabel()}
|
||||
label={actualTimeRange ?? adhocFilter.getDefaultLabel(options)}
|
||||
tooltipTitle={title ?? adhocFilter.getTooltipTitle()}
|
||||
onRemove={() =>
|
||||
onRemoveFilter({
|
||||
|
||||
+32
-2
@@ -63,9 +63,35 @@ export const OPERATORS_TO_SQL = {
|
||||
`= '{{ presto.latest_partition('${datasource.schema}.${datasource.datasource_name}') }}'`,
|
||||
};
|
||||
|
||||
export interface VerboseColumn {
|
||||
column_name?: string;
|
||||
verbose_name?: string | null;
|
||||
}
|
||||
|
||||
// Resolves the display label for a filter's subject: the verbose_name of the
|
||||
// matching column when one is supplied, falling back to the technical
|
||||
// subject used for SQL generation.
|
||||
const getDisplaySubject = (
|
||||
subject: string | { column_name?: string } | null | undefined,
|
||||
columns?: VerboseColumn[],
|
||||
) => {
|
||||
if (!columns) {
|
||||
return subject ?? undefined;
|
||||
}
|
||||
const columnName =
|
||||
typeof subject === 'object' ? subject?.column_name : subject;
|
||||
const verboseName = columns.find(
|
||||
column => column.column_name === columnName,
|
||||
)?.verbose_name;
|
||||
return verboseName || (subject ?? undefined);
|
||||
};
|
||||
|
||||
export const translateToSql = (
|
||||
adhocFilter: AdhocFilter,
|
||||
{ useSimple }: { useSimple: boolean } = { useSimple: false },
|
||||
{
|
||||
useSimple,
|
||||
columns,
|
||||
}: { useSimple?: boolean; columns?: VerboseColumn[] } = {},
|
||||
) => {
|
||||
if (isSimpleAdhocFilter(adhocFilter) || useSimple) {
|
||||
const { subject, operator } = adhocFilter as SimpleAdhocFilter;
|
||||
@@ -81,7 +107,11 @@ export const translateToSql = (
|
||||
OPERATORS_TO_SQL[operator](adhocFilter)
|
||||
: // @ts-expect-error TODO: fix missing operator type `NOT LIKE` and `TEMPORAL RANGE`.
|
||||
OPERATORS_TO_SQL[operator];
|
||||
return getSimpleSQLExpression(subject, op, comparator);
|
||||
return getSimpleSQLExpression(
|
||||
getDisplaySubject(subject, columns),
|
||||
op,
|
||||
comparator,
|
||||
);
|
||||
}
|
||||
if (isFreeFormAdhocFilter(adhocFilter)) {
|
||||
return adhocFilter.sqlExpression;
|
||||
|
||||
+5
-4
@@ -22,10 +22,11 @@ import FixedOrMetricControl from '.';
|
||||
jest.mock(
|
||||
'@superset-ui/core/components/Icons/AsyncIcon',
|
||||
() =>
|
||||
({ fileName }: { fileName: string }) => (
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
|
||||
<span role="img" aria-label={fileName.replace('_', '-')} />
|
||||
),
|
||||
({ fileName }: { fileName: string }) =>
|
||||
(
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
|
||||
<span role="img" aria-label={fileName.replace('_', '-')} />
|
||||
),
|
||||
);
|
||||
|
||||
const createProps = () => ({
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import exploreReducer, { ExploreState } from './exploreReducer';
|
||||
import { setStashFormData } from '../actions/exploreActions';
|
||||
import { QueryFormData } from '@superset-ui/core';
|
||||
import { sections, CustomControlItem } from '@superset-ui/chart-controls';
|
||||
import { getControlStateFromControlConfig } from 'src/explore/controlUtils';
|
||||
import exploreReducer, { ExploreState } from './exploreReducer';
|
||||
import { setControlValue, setStashFormData } from '../actions/exploreActions';
|
||||
|
||||
test('reset hiddenFormData on SET_STASH_FORM_DATA', () => {
|
||||
const initialState: ExploreState = {
|
||||
@@ -52,3 +54,72 @@ test('skips updates when the field is already updated on SET_STASH_FORM_DATA', (
|
||||
const newState = exploreReducer(initialState, restoreAction);
|
||||
expect(newState).toBe(initialState);
|
||||
});
|
||||
|
||||
// Regression guard for the shared Time Comparison section (used by the Table
|
||||
// chart, among others): selecting "Custom date" for Time shift and then
|
||||
// clearing "Shift start date" raises a required-date validation error. When the
|
||||
// user then switches Time shift to a non-custom preset the error must clear.
|
||||
// Because `start_date_offset` did not declare `validationDependencies` on
|
||||
// `time_compare`, SET_FIELD_VALUE never re-ran its mapStateToProps and the stale
|
||||
// error survived in Redux, blocking further chart updates until a page refresh.
|
||||
test('SET_FIELD_VALUE clears the custom-shift date error when time_compare leaves "custom"', () => {
|
||||
const REQUIRED_DATE_ERROR = 'A date is required when using custom date shift';
|
||||
const timeComparisonSection = sections.timeComparisonControls({
|
||||
multi: false,
|
||||
showCalculationType: false,
|
||||
showFullChoices: false,
|
||||
});
|
||||
const timeCompareConfig = (
|
||||
timeComparisonSection.controlSetRows[0][0] as CustomControlItem
|
||||
).config;
|
||||
const startDateOffsetConfig = (
|
||||
timeComparisonSection.controlSetRows[1][0] as CustomControlItem
|
||||
).config;
|
||||
|
||||
const form_data = {
|
||||
time_compare: 'custom',
|
||||
start_date_offset: '2021-01-01',
|
||||
} as unknown as QueryFormData;
|
||||
|
||||
// Build the control states the way the explore store does so they carry the
|
||||
// real mapStateToProps / validationDependencies from the control config.
|
||||
const controlPanelState = { controls: {}, form_data };
|
||||
const initialState: ExploreState = {
|
||||
form_data,
|
||||
controls: {
|
||||
time_compare: getControlStateFromControlConfig(
|
||||
timeCompareConfig,
|
||||
controlPanelState,
|
||||
'custom',
|
||||
)!,
|
||||
start_date_offset: getControlStateFromControlConfig(
|
||||
startDateOffsetConfig,
|
||||
controlPanelState,
|
||||
'2021-01-01',
|
||||
)!,
|
||||
},
|
||||
};
|
||||
|
||||
// A valid custom date starts without a validation error.
|
||||
expect(initialState.controls.start_date_offset.validationErrors).toEqual([]);
|
||||
|
||||
// 1) Clearing "Shift start date" raises the required-date error (expected).
|
||||
const afterClear = exploreReducer(
|
||||
initialState,
|
||||
setControlValue('start_date_offset', '') as Parameters<
|
||||
typeof exploreReducer
|
||||
>[1],
|
||||
);
|
||||
expect(afterClear.controls.start_date_offset.validationErrors).toEqual([
|
||||
REQUIRED_DATE_ERROR,
|
||||
]);
|
||||
|
||||
// 2) Switching Time shift to a non-custom preset must clear the stale error.
|
||||
const afterSwitch = exploreReducer(
|
||||
afterClear,
|
||||
setControlValue('time_compare', '1 week ago') as Parameters<
|
||||
typeof exploreReducer
|
||||
>[1],
|
||||
);
|
||||
expect(afterSwitch.controls.start_date_offset.validationErrors).toEqual([]);
|
||||
});
|
||||
|
||||
+5
-4
@@ -38,10 +38,11 @@ import {
|
||||
jest.mock(
|
||||
'@superset-ui/core/components/Icons/AsyncIcon',
|
||||
() =>
|
||||
({ fileName }: { fileName: string }) => (
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
|
||||
<span role="img" aria-label={fileName.replace('_', '-')} />
|
||||
),
|
||||
({ fileName }: { fileName: string }) =>
|
||||
(
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
|
||||
<span role="img" aria-label={fileName.replace('_', '-')} />
|
||||
),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
|
||||
+5
-4
@@ -29,10 +29,11 @@ import DatasetPanelWrapper from 'src/features/datasets/AddDataset/DatasetPanel';
|
||||
jest.mock(
|
||||
'@superset-ui/core/components/Icons/AsyncIcon',
|
||||
() =>
|
||||
({ fileName }: { fileName: string }) => (
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
|
||||
<span role="img" aria-label={fileName.replace('_', '-')} />
|
||||
),
|
||||
({ fileName }: { fileName: string }) =>
|
||||
(
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
|
||||
<span role="img" aria-label={fileName.replace('_', '-')} />
|
||||
),
|
||||
);
|
||||
|
||||
const errorMessageRegistry = getErrorMessageComponentRegistry();
|
||||
|
||||
@@ -72,6 +72,12 @@ export const PermissionsField = ({
|
||||
.replace(/_/g, ' ')
|
||||
.includes(input.toLowerCase().replace(/_/g, ' '))
|
||||
}
|
||||
// Permission labels are long ("all datasource access on all_datasource_access",
|
||||
// "can write on DashboardFilterStateRestApi"), and the dropdown otherwise
|
||||
// inherits the trigger's width inside the modal, so every option was truncated
|
||||
// to the point of being indistinguishable. Let the popup size to its content
|
||||
// instead. See #40430.
|
||||
popupMatchSelectWidth={false}
|
||||
getPopupContainer={trigger => trigger.closest('.ant-modal-container')}
|
||||
data-test="permissions-select"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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 {
|
||||
automaticNormalizationTransitions,
|
||||
isJsonValue,
|
||||
matchingAutomaticNormalizationTransitions,
|
||||
stashDropNormalizationTransitions,
|
||||
} from './normalization';
|
||||
|
||||
test('recognizes only values that JSON can represent faithfully', () => {
|
||||
expect(isJsonValue({ nested: [null, true, 3, 'value'] })).toBe(true);
|
||||
expect(isJsonValue(Number.NaN)).toBe(false);
|
||||
expect(isJsonValue(Number.POSITIVE_INFINITY)).toBe(false);
|
||||
expect(isJsonValue(new Date())).toBe(false);
|
||||
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
expect(isJsonValue(cyclic)).toBe(false);
|
||||
});
|
||||
|
||||
test('records hydration changes only when input matches persisted data', () => {
|
||||
expect(
|
||||
automaticNormalizationTransitions(
|
||||
{ row_limit: null },
|
||||
{ row_limit: null },
|
||||
{ row_limit: 10000, show_legend: true },
|
||||
),
|
||||
).toEqual({
|
||||
row_limit: {
|
||||
control: 'row_limit',
|
||||
from_present: true,
|
||||
from_value: null,
|
||||
to_present: true,
|
||||
to_value: 10000,
|
||||
},
|
||||
show_legend: {
|
||||
control: 'show_legend',
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
automaticNormalizationTransitions(
|
||||
{ row_limit: null },
|
||||
{ row_limit: 500 },
|
||||
{ row_limit: 10000 },
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('does not interpret a missing hydrated control as normalization', () => {
|
||||
expect(
|
||||
automaticNormalizationTransitions(
|
||||
{ obsolete_control: true },
|
||||
{ obsolete_control: true },
|
||||
{},
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('keeps only valid, unchanged transitions for a save', () => {
|
||||
const rowLimit = {
|
||||
control: 'row_limit',
|
||||
from_present: true as const,
|
||||
from_value: null,
|
||||
to_present: true as const,
|
||||
to_value: 10000,
|
||||
};
|
||||
const tracking = {
|
||||
chartId: 7,
|
||||
hydrationSessionId: 'hydration-a',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: { show_legend: true as const },
|
||||
transitions: {
|
||||
row_limit: rowLimit,
|
||||
show_legend: {
|
||||
control: 'show_legend',
|
||||
from_present: false as const,
|
||||
to_present: true as const,
|
||||
to_value: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
matchingAutomaticNormalizationTransitions(tracking, {
|
||||
row_limit: 10000,
|
||||
show_legend: true,
|
||||
}),
|
||||
).toEqual({ row_limit: rowLimit });
|
||||
});
|
||||
|
||||
test('covers a stash-removed key still equal to its persisted value', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions(
|
||||
{ order_desc: true, row_limit: 5000 },
|
||||
{ order_desc: true },
|
||||
{ row_limit: 5000 },
|
||||
),
|
||||
).toEqual({
|
||||
order_desc: {
|
||||
control: 'order_desc',
|
||||
from_present: true,
|
||||
from_value: true,
|
||||
to_present: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('does not cover a stashed value the user changed before it was hidden', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions(
|
||||
{ server_page_length: 10 },
|
||||
{ server_page_length: 25 },
|
||||
{},
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('does not cover stashed keys that were never persisted', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions({}, { totals_aggregate: 'SUM' }, {}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('does not cover keys the outgoing payload still carries', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions(
|
||||
{ order_desc: true },
|
||||
{ order_desc: true },
|
||||
{ order_desc: true },
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('drop coverage requires a stash', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions({ order_desc: true }, undefined, {}),
|
||||
).toEqual({});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 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 isEqual from 'lodash-es/isEqual';
|
||||
import type {
|
||||
AutomaticNormalizationTransition,
|
||||
AutomaticNormalizationTransitions,
|
||||
ChartNormalizationTrackingState,
|
||||
JsonValue,
|
||||
} from './types';
|
||||
|
||||
const isJsonValueInternal = (
|
||||
value: unknown,
|
||||
ancestors: WeakSet<object>,
|
||||
): value is JsonValue => {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value);
|
||||
}
|
||||
if (typeof value !== 'object' || ancestors.has(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ancestors.add(value);
|
||||
let isJsonCompatible: boolean;
|
||||
if (Array.isArray(value)) {
|
||||
isJsonCompatible = value.every(item =>
|
||||
isJsonValueInternal(item, ancestors),
|
||||
);
|
||||
} else {
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
isJsonCompatible =
|
||||
(prototype === Object.prototype || prototype === null) &&
|
||||
Object.values(value).every(item => isJsonValueInternal(item, ancestors));
|
||||
}
|
||||
ancestors.delete(value);
|
||||
return isJsonCompatible;
|
||||
};
|
||||
|
||||
export const isJsonValue = (value: unknown): value is JsonValue =>
|
||||
isJsonValueInternal(value, new WeakSet());
|
||||
|
||||
/** Structural equality for JSON values, independent of object key order. */
|
||||
export const jsonValuesEqual = (left: unknown, right: unknown) =>
|
||||
isEqual(left, right);
|
||||
|
||||
interface NormalizationSnapshots {
|
||||
control: string;
|
||||
persisted: Record<string, unknown>;
|
||||
input: Record<string, unknown>;
|
||||
hydrated: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const automaticNormalizationTransition = ({
|
||||
control,
|
||||
persisted,
|
||||
input,
|
||||
hydrated,
|
||||
}: NormalizationSnapshots): AutomaticNormalizationTransition | undefined => {
|
||||
const fromPresent = Object.hasOwn(persisted, control);
|
||||
const inputPresent = Object.hasOwn(input, control);
|
||||
const toPresent = Object.hasOwn(hydrated, control);
|
||||
const fromValue = persisted[control];
|
||||
const inputValue = input[control];
|
||||
const toValue = hydrated[control];
|
||||
|
||||
const inputMatchesPersisted =
|
||||
fromPresent === inputPresent && jsonValuesEqual(fromValue, inputValue);
|
||||
const hydrationChangedValue =
|
||||
fromPresent !== toPresent || !jsonValuesEqual(fromValue, toValue);
|
||||
|
||||
// Disappearing keys (!toPresent) are deliberately not covered here:
|
||||
// hydration itself never removes keys from the merged snapshot. Machine
|
||||
// removals happen later, when StashFormDataContainer stashes invisible
|
||||
// controls out of form_data — those are covered at save time by
|
||||
// stashDropNormalizationTransitions, which uses the stash itself
|
||||
// (explore.hiddenFormData) as the proof the removal was not user-made.
|
||||
if (!inputMatchesPersisted || !toPresent || !hydrationChangedValue) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isJsonValue(toValue)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!fromPresent) {
|
||||
return {
|
||||
control,
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: toValue,
|
||||
};
|
||||
}
|
||||
if (!isJsonValue(fromValue)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
control,
|
||||
from_present: true,
|
||||
from_value: fromValue,
|
||||
to_present: true,
|
||||
to_value: toValue,
|
||||
};
|
||||
};
|
||||
|
||||
export const automaticNormalizationTransitions = (
|
||||
persisted: Record<string, unknown>,
|
||||
input: Record<string, unknown>,
|
||||
hydrated: Record<string, unknown>,
|
||||
): AutomaticNormalizationTransitions => {
|
||||
const transitions: AutomaticNormalizationTransitions = {};
|
||||
const controls = new Set([...Object.keys(input), ...Object.keys(hydrated)]);
|
||||
controls.forEach(control => {
|
||||
const transition = automaticNormalizationTransition({
|
||||
control,
|
||||
persisted,
|
||||
input,
|
||||
hydrated,
|
||||
});
|
||||
if (transition) {
|
||||
transitions[control] = transition;
|
||||
}
|
||||
});
|
||||
return transitions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Advisory transitions for keys the stash removed from form_data.
|
||||
*
|
||||
* StashFormDataContainer moves an invisible control's value out of
|
||||
* ``form_data`` into ``explore.hiddenFormData``. That removal is
|
||||
* machine-made by construction, but it happens in render effects after
|
||||
* hydration, so hydration-time tracking cannot see it. This computes the
|
||||
* matching drop transitions at save time: a key counts only when the stash
|
||||
* holds it, the stashed value still equals the persisted value (a user edit
|
||||
* before hiding breaks the equality and stays recorded), and the outgoing
|
||||
* payload no longer carries the key. Keys absent from the stash — e.g.
|
||||
* removed by a viz-type switch — are never covered.
|
||||
*/
|
||||
export const stashDropNormalizationTransitions = (
|
||||
persisted: Record<string, unknown>,
|
||||
hiddenFormData: Record<string, unknown> | undefined,
|
||||
outgoingFormData: Record<string, unknown>,
|
||||
): AutomaticNormalizationTransitions => {
|
||||
const transitions: AutomaticNormalizationTransitions = {};
|
||||
if (!hiddenFormData) {
|
||||
return transitions;
|
||||
}
|
||||
Object.keys(hiddenFormData).forEach(control => {
|
||||
if (!Object.hasOwn(persisted, control)) return;
|
||||
if (Object.hasOwn(outgoingFormData, control)) return;
|
||||
const fromValue = persisted[control];
|
||||
if (!isJsonValue(fromValue)) return;
|
||||
if (!jsonValuesEqual(hiddenFormData[control], fromValue)) return;
|
||||
transitions[control] = {
|
||||
control,
|
||||
from_present: true,
|
||||
from_value: fromValue,
|
||||
to_present: false,
|
||||
};
|
||||
});
|
||||
return transitions;
|
||||
};
|
||||
|
||||
export const matchingAutomaticNormalizationTransitions = (
|
||||
tracking: ChartNormalizationTrackingState | null | undefined,
|
||||
formData: Record<string, unknown>,
|
||||
): AutomaticNormalizationTransitions =>
|
||||
Object.fromEntries(
|
||||
Object.entries(tracking?.transitions ?? {}).filter(
|
||||
([control, transition]) =>
|
||||
!tracking?.invalidatedControls[control] &&
|
||||
Object.hasOwn(formData, control) === transition.to_present &&
|
||||
(!transition.to_present ||
|
||||
jsonValuesEqual(formData[control], transition.to_value)),
|
||||
),
|
||||
);
|
||||
@@ -18,10 +18,14 @@
|
||||
*/
|
||||
import versionHistoryReducer, {
|
||||
appendVersionSessionLog,
|
||||
beginChartNormalizationSave,
|
||||
clearVersionPreview,
|
||||
clearVersionSessionLog,
|
||||
completeChartNormalizationSave,
|
||||
closeVersionHistoryPanel,
|
||||
openVersionHistoryPanel,
|
||||
hydrateChartNormalization,
|
||||
invalidateChartNormalizationControls,
|
||||
selectIsChartVersionPreviewActive,
|
||||
selectIsDashboardVersionPreviewActive,
|
||||
selectVersionHistory,
|
||||
@@ -145,3 +149,55 @@ test('per-entity preview selectors only match their own entity type', () => {
|
||||
expect(selectIsChartVersionPreviewActive(state)).toBe(true);
|
||||
expect(selectIsDashboardVersionPreviewActive(state)).toBe(false);
|
||||
});
|
||||
|
||||
test('normalization tracking invalidates controls without re-adding transitions', () => {
|
||||
let state = versionHistoryReducer(
|
||||
initial,
|
||||
hydrateChartNormalization({
|
||||
chartId: 7,
|
||||
hydrationSessionId: 'session-a',
|
||||
transitions: {
|
||||
row_limit: {
|
||||
control: 'row_limit',
|
||||
from_present: true,
|
||||
from_value: null,
|
||||
to_present: true,
|
||||
to_value: 10000,
|
||||
},
|
||||
},
|
||||
invalidatedControls: {},
|
||||
saveAttemptId: null,
|
||||
}),
|
||||
);
|
||||
state = versionHistoryReducer(
|
||||
state,
|
||||
invalidateChartNormalizationControls(['row_limit']),
|
||||
);
|
||||
expect(state.chartNormalization?.invalidatedControls).toEqual({
|
||||
row_limit: true,
|
||||
});
|
||||
expect(state.chartNormalization?.transitions.row_limit).toBeDefined();
|
||||
});
|
||||
|
||||
test('late save completion cannot rebase another hydration session', () => {
|
||||
let state = versionHistoryReducer(
|
||||
initial,
|
||||
hydrateChartNormalization({
|
||||
chartId: 7,
|
||||
hydrationSessionId: 'session-b',
|
||||
transitions: {},
|
||||
invalidatedControls: {},
|
||||
saveAttemptId: null,
|
||||
}),
|
||||
);
|
||||
state = versionHistoryReducer(
|
||||
state,
|
||||
beginChartNormalizationSave(7, 'session-b', 'attempt-b'),
|
||||
);
|
||||
const unchanged = versionHistoryReducer(
|
||||
state,
|
||||
completeChartNormalizationSave(7, 'session-a', 'attempt-a', {}),
|
||||
);
|
||||
expect(unchanged).toBe(state);
|
||||
expect(unchanged.chartNormalization?.saveAttemptId).toBe('attempt-b');
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
*/
|
||||
import type {
|
||||
ActivityInclude,
|
||||
AutomaticNormalizationTransitions,
|
||||
ChartNormalizationTrackingState,
|
||||
SessionLogEntry,
|
||||
VersionedEntityType,
|
||||
VersionHistoryState,
|
||||
@@ -33,6 +35,12 @@ export const VERSION_PREVIEW_APPLIED = 'VERSION_PREVIEW_APPLIED';
|
||||
export const VERSION_RESTORED = 'VERSION_RESTORED';
|
||||
export const APPEND_VERSION_SESSION_LOG = 'APPEND_VERSION_SESSION_LOG';
|
||||
export const CLEAR_VERSION_SESSION_LOG = 'CLEAR_VERSION_SESSION_LOG';
|
||||
export const HYDRATE_CHART_NORMALIZATION = 'HYDRATE_CHART_NORMALIZATION';
|
||||
export const INVALIDATE_CHART_NORMALIZATION_CONTROLS =
|
||||
'INVALIDATE_CHART_NORMALIZATION_CONTROLS';
|
||||
export const BEGIN_CHART_NORMALIZATION_SAVE = 'BEGIN_CHART_NORMALIZATION_SAVE';
|
||||
export const COMPLETE_CHART_NORMALIZATION_SAVE =
|
||||
'COMPLETE_CHART_NORMALIZATION_SAVE';
|
||||
|
||||
/** Upper bound on retained unsaved-edit entries; older ones drop off. */
|
||||
export const MAX_SESSION_LOG_ENTRIES = 50;
|
||||
@@ -86,6 +94,31 @@ interface ClearSessionLogAction {
|
||||
type: typeof CLEAR_VERSION_SESSION_LOG;
|
||||
}
|
||||
|
||||
interface HydrateChartNormalizationAction {
|
||||
type: typeof HYDRATE_CHART_NORMALIZATION;
|
||||
tracking: ChartNormalizationTrackingState;
|
||||
}
|
||||
|
||||
interface InvalidateChartNormalizationControlsAction {
|
||||
type: typeof INVALIDATE_CHART_NORMALIZATION_CONTROLS;
|
||||
controls: string[];
|
||||
}
|
||||
|
||||
interface BeginChartNormalizationSaveAction {
|
||||
type: typeof BEGIN_CHART_NORMALIZATION_SAVE;
|
||||
chartId: number;
|
||||
hydrationSessionId: string;
|
||||
saveAttemptId: string;
|
||||
}
|
||||
|
||||
interface CompleteChartNormalizationSaveAction {
|
||||
type: typeof COMPLETE_CHART_NORMALIZATION_SAVE;
|
||||
chartId: number;
|
||||
hydrationSessionId: string;
|
||||
saveAttemptId: string;
|
||||
transitions: AutomaticNormalizationTransitions;
|
||||
}
|
||||
|
||||
export type VersionHistoryAction =
|
||||
| OpenPanelAction
|
||||
| ClosePanelAction
|
||||
@@ -95,7 +128,11 @@ export type VersionHistoryAction =
|
||||
| PreviewAppliedAction
|
||||
| VersionRestoredAction
|
||||
| AppendSessionLogAction
|
||||
| ClearSessionLogAction;
|
||||
| ClearSessionLogAction
|
||||
| HydrateChartNormalizationAction
|
||||
| InvalidateChartNormalizationControlsAction
|
||||
| BeginChartNormalizationSaveAction
|
||||
| CompleteChartNormalizationSaveAction;
|
||||
|
||||
export const openVersionHistoryPanel = (
|
||||
entityType: VersionedEntityType,
|
||||
@@ -161,6 +198,44 @@ export const clearVersionSessionLog = (): ClearSessionLogAction => ({
|
||||
type: CLEAR_VERSION_SESSION_LOG,
|
||||
});
|
||||
|
||||
export const hydrateChartNormalization = (
|
||||
tracking: ChartNormalizationTrackingState,
|
||||
): HydrateChartNormalizationAction => ({
|
||||
type: HYDRATE_CHART_NORMALIZATION,
|
||||
tracking,
|
||||
});
|
||||
|
||||
export const invalidateChartNormalizationControls = (
|
||||
controls: string[],
|
||||
): InvalidateChartNormalizationControlsAction => ({
|
||||
type: INVALIDATE_CHART_NORMALIZATION_CONTROLS,
|
||||
controls,
|
||||
});
|
||||
|
||||
export const beginChartNormalizationSave = (
|
||||
chartId: number,
|
||||
hydrationSessionId: string,
|
||||
saveAttemptId: string,
|
||||
): BeginChartNormalizationSaveAction => ({
|
||||
type: BEGIN_CHART_NORMALIZATION_SAVE,
|
||||
chartId,
|
||||
hydrationSessionId,
|
||||
saveAttemptId,
|
||||
});
|
||||
|
||||
export const completeChartNormalizationSave = (
|
||||
chartId: number,
|
||||
hydrationSessionId: string,
|
||||
saveAttemptId: string,
|
||||
transitions: AutomaticNormalizationTransitions,
|
||||
): CompleteChartNormalizationSaveAction => ({
|
||||
type: COMPLETE_CHART_NORMALIZATION_SAVE,
|
||||
chartId,
|
||||
hydrationSessionId,
|
||||
saveAttemptId,
|
||||
transitions,
|
||||
});
|
||||
|
||||
const initialState: VersionHistoryState = {
|
||||
isPanelOpen: false,
|
||||
entityType: null,
|
||||
@@ -170,6 +245,7 @@ const initialState: VersionHistoryState = {
|
||||
sessionLog: [],
|
||||
restoreCount: 0,
|
||||
lastRestoredEntityUuid: null,
|
||||
chartNormalization: null,
|
||||
};
|
||||
|
||||
export default function versionHistoryReducer(
|
||||
@@ -240,6 +316,58 @@ export default function versionHistoryReducer(
|
||||
}
|
||||
case CLEAR_VERSION_SESSION_LOG:
|
||||
return { ...state, sessionLog: [] };
|
||||
case HYDRATE_CHART_NORMALIZATION:
|
||||
return { ...state, chartNormalization: action.tracking };
|
||||
case INVALIDATE_CHART_NORMALIZATION_CONTROLS: {
|
||||
if (!state.chartNormalization || action.controls.length === 0) {
|
||||
return state;
|
||||
}
|
||||
const invalidatedControls = {
|
||||
...state.chartNormalization.invalidatedControls,
|
||||
};
|
||||
action.controls.forEach(control => {
|
||||
invalidatedControls[control] = true;
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
chartNormalization: {
|
||||
...state.chartNormalization,
|
||||
invalidatedControls,
|
||||
},
|
||||
};
|
||||
}
|
||||
case BEGIN_CHART_NORMALIZATION_SAVE:
|
||||
if (
|
||||
state.chartNormalization?.chartId !== action.chartId ||
|
||||
state.chartNormalization.hydrationSessionId !==
|
||||
action.hydrationSessionId
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
chartNormalization: {
|
||||
...state.chartNormalization,
|
||||
saveAttemptId: action.saveAttemptId,
|
||||
},
|
||||
};
|
||||
case COMPLETE_CHART_NORMALIZATION_SAVE:
|
||||
if (
|
||||
state.chartNormalization?.chartId !== action.chartId ||
|
||||
state.chartNormalization.hydrationSessionId !==
|
||||
action.hydrationSessionId ||
|
||||
state.chartNormalization.saveAttemptId !== action.saveAttemptId
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
chartNormalization: {
|
||||
...state.chartNormalization,
|
||||
transitions: action.transitions,
|
||||
saveAttemptId: null,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -292,3 +420,6 @@ export const selectVersionLastRestoredUuid = (state: VersionHistoryRootState) =>
|
||||
|
||||
export const selectVersionSessionLog = (state: VersionHistoryRootState) =>
|
||||
selectVersionHistory(state).sessionLog;
|
||||
|
||||
export const selectChartNormalization = (state: VersionHistoryRootState) =>
|
||||
selectVersionHistory(state).chartNormalization;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { versionSessionLogMiddleware } from './sessionLogMiddleware';
|
||||
import {
|
||||
APPEND_VERSION_SESSION_LOG,
|
||||
CLEAR_VERSION_SESSION_LOG,
|
||||
INVALIDATE_CHART_NORMALIZATION_CONTROLS,
|
||||
} from './reducer';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
@@ -77,7 +78,7 @@ test('falls back to a humanized control name when no label exists', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('skips programmatic control writes so untouched charts stay clean', async () => {
|
||||
test('programmatic writes invalidate normalization without logging an edit', async () => {
|
||||
// Effects rewrite controls with no user gesture (transferred-control
|
||||
// cleanup after load, derived margins); logging them would report unsaved
|
||||
// edits the user never made. Built with the REAL action creator so the
|
||||
@@ -89,15 +90,54 @@ test('skips programmatic control writes so untouched charts stay clean', async (
|
||||
explore: { controls: { metrics: { label: 'Metrics' } } },
|
||||
});
|
||||
run(store, setControlValue('metrics', [], undefined, { programmatic: true }));
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
expect(store.dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(store.dispatch).toHaveBeenCalledWith({
|
||||
type: INVALIDATE_CHART_NORMALIZATION_CONTROLS,
|
||||
controls: ['metrics'],
|
||||
});
|
||||
|
||||
// The same creator without the mark still logs.
|
||||
store.dispatch.mockClear();
|
||||
run(store, setControlValue('metrics', []));
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: APPEND_VERSION_SESSION_LOG }),
|
||||
);
|
||||
});
|
||||
|
||||
test('a write matching the hydrated default preserves normalization', async () => {
|
||||
const { setControlValue } =
|
||||
await import('src/explore/actions/exploreActions');
|
||||
const store = buildStore({
|
||||
explore: {
|
||||
controls: { show_totals: { label: 'Show totals' } },
|
||||
form_data: { show_totals: false },
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: 7,
|
||||
hydrationSessionId: 'hydration-a',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: {},
|
||||
transitions: {
|
||||
show_totals: {
|
||||
control: 'show_totals',
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
run(
|
||||
store,
|
||||
setControlValue('show_totals', false, undefined, { programmatic: true }),
|
||||
);
|
||||
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clears the session log when the explore page hydrates', () => {
|
||||
const store = buildStore();
|
||||
run(store, { type: 'HYDRATE_EXPLORE', data: {} });
|
||||
@@ -188,7 +228,7 @@ test('a history step cannot collapse into an adjacent control entry', async () =
|
||||
await import('src/explore/actions/exploreActions');
|
||||
const store = buildStore({ explore: { controls: {} } });
|
||||
run(store, setExploreControls({} as never));
|
||||
const { entry } = store.dispatch.mock.calls[0][0];
|
||||
const [[{ entry }]] = store.dispatch.mock.calls;
|
||||
expect(entry.controlName).not.toMatch(/^[a-z]/);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,13 @@
|
||||
import type { Middleware } from 'redux';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { appendVersionSessionLog, clearVersionSessionLog } from './reducer';
|
||||
import {
|
||||
appendVersionSessionLog,
|
||||
clearVersionSessionLog,
|
||||
invalidateChartNormalizationControls,
|
||||
} from './reducer';
|
||||
import { jsonValuesEqual } from './normalization';
|
||||
import type { ChartNormalizationTrackingState } from './types';
|
||||
|
||||
// Action types are inlined (rather than imported from the explore
|
||||
// module) so this middleware does not pull explore code into every
|
||||
@@ -47,9 +53,54 @@ interface SessionLogState {
|
||||
user?: { firstName?: string; lastName?: string };
|
||||
explore?: {
|
||||
controls?: Record<string, { label?: unknown } | undefined>;
|
||||
form_data?: Record<string, unknown>;
|
||||
};
|
||||
versionHistory?: {
|
||||
chartNormalization?: ChartNormalizationTrackingState | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Untrusted Explore action shape; fields narrow only at this boundary. */
|
||||
interface ExploreBoundaryAction {
|
||||
type: unknown;
|
||||
controlName?: unknown;
|
||||
formData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const changedFormDataKeys = (
|
||||
before: Record<string, unknown> = {},
|
||||
after: Record<string, unknown> = {},
|
||||
) =>
|
||||
[...new Set([...Object.keys(before), ...Object.keys(after)])].filter(
|
||||
key => before[key] !== after[key],
|
||||
);
|
||||
|
||||
/**
|
||||
* Anti-corruption adapter from Explore's action vocabulary to the stable
|
||||
* versioning concept of controls whose user-intent evidence is no longer valid.
|
||||
*/
|
||||
export const normalizationControlsChangedByExplore = (
|
||||
action: ExploreBoundaryAction,
|
||||
before: Record<string, unknown> | undefined,
|
||||
after: Record<string, unknown> | undefined,
|
||||
) => {
|
||||
if (action.type === HYDRATE_EXPLORE) {
|
||||
return [];
|
||||
}
|
||||
const controls = changedFormDataKeys(before, after);
|
||||
if (
|
||||
action.type === SET_FIELD_VALUE &&
|
||||
typeof action.controlName === 'string'
|
||||
) {
|
||||
controls.push(action.controlName);
|
||||
} else if (action.type === SET_EXPLORE_CONTROLS && action.formData) {
|
||||
controls.push(...Object.keys(action.formData));
|
||||
} else if (action.type === UPDATE_FORM_DATA_BY_DATASOURCE) {
|
||||
controls.push(DATASOURCE_CONTROL_NAME);
|
||||
}
|
||||
return [...new Set(controls)];
|
||||
};
|
||||
|
||||
function controlLabel(state: SessionLogState, controlName: string): string {
|
||||
const label = state.explore?.controls?.[controlName]?.label;
|
||||
return typeof label === 'string' && label
|
||||
@@ -64,6 +115,25 @@ function userName(state: SessionLogState): string | null {
|
||||
return name || null;
|
||||
}
|
||||
|
||||
const normalizationControlsNoLongerMatching = (
|
||||
controls: string[],
|
||||
state: SessionLogState,
|
||||
) => {
|
||||
const formData = state.explore?.form_data ?? {};
|
||||
const transitions = state.versionHistory?.chartNormalization?.transitions;
|
||||
return controls.filter(control => {
|
||||
const transition = transitions?.[control];
|
||||
if (!transition) {
|
||||
return true;
|
||||
}
|
||||
const present = Object.hasOwn(formData, control);
|
||||
return (
|
||||
present !== transition.to_present ||
|
||||
(present && !jsonValuesEqual(formData[control], transition.to_value))
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Records unsaved explore control changes in the version history
|
||||
* session log ("Current version" section) and resets the log whenever
|
||||
@@ -71,6 +141,7 @@ function userName(state: SessionLogState): string | null {
|
||||
*/
|
||||
export const versionSessionLogMiddleware: Middleware =
|
||||
store => next => action => {
|
||||
const before = (store.getState() as SessionLogState).explore?.form_data;
|
||||
const result = next(action);
|
||||
if (!isFeatureEnabled(FeatureFlag.VersionHistory)) {
|
||||
return result;
|
||||
@@ -151,5 +222,17 @@ export const versionSessionLogMiddleware: Middleware =
|
||||
}),
|
||||
);
|
||||
}
|
||||
const state = store.getState() as SessionLogState;
|
||||
const changedControls = normalizationControlsNoLongerMatching(
|
||||
normalizationControlsChangedByExplore(
|
||||
action,
|
||||
before,
|
||||
state.explore?.form_data,
|
||||
),
|
||||
state,
|
||||
);
|
||||
if (changedControls.length) {
|
||||
store.dispatch(invalidateChartNormalizationControls(changedControls));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -209,6 +209,45 @@ export interface SessionLogEntry {
|
||||
user: string | null;
|
||||
}
|
||||
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
type PresentNormalizationValue<Prefix extends 'from' | 'to'> =
|
||||
Prefix extends 'from'
|
||||
? { from_present: true; from_value: JsonValue }
|
||||
: { to_present: true; to_value: JsonValue };
|
||||
|
||||
type MissingNormalizationValue<Prefix extends 'from' | 'to'> =
|
||||
Prefix extends 'from'
|
||||
? { from_present: false; from_value?: never }
|
||||
: { to_present: false; to_value?: never };
|
||||
|
||||
/** One guarded hydration transition sent with an existing-chart overwrite. */
|
||||
export type AutomaticNormalizationTransition = { control: string } & (
|
||||
| PresentNormalizationValue<'from'>
|
||||
| MissingNormalizationValue<'from'>
|
||||
) &
|
||||
(PresentNormalizationValue<'to'> | MissingNormalizationValue<'to'>);
|
||||
|
||||
export type AutomaticNormalizationTransitions = Record<
|
||||
string,
|
||||
AutomaticNormalizationTransition
|
||||
>;
|
||||
|
||||
/** Identity-bound state for one chart hydration and its in-flight save. */
|
||||
export interface ChartNormalizationTrackingState {
|
||||
chartId: number;
|
||||
hydrationSessionId: string;
|
||||
transitions: AutomaticNormalizationTransitions;
|
||||
invalidatedControls: Record<string, true>;
|
||||
saveAttemptId: string | null;
|
||||
}
|
||||
|
||||
export interface VersionHistoryState {
|
||||
isPanelOpen: boolean;
|
||||
entityType: VersionedEntityType | null;
|
||||
@@ -228,4 +267,6 @@ export interface VersionHistoryState {
|
||||
* the one their page shows.
|
||||
*/
|
||||
lastRestoredEntityUuid: string | null;
|
||||
/** Advisory transitions for the active Explore chart hydration. */
|
||||
chartNormalization?: ChartNormalizationTrackingState | null;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,21 @@ function findEndpoint(spy: jest.SpyInstance, substring: string): string {
|
||||
return (match[0] as Record<string, string>).endpoint;
|
||||
}
|
||||
|
||||
function deferredJsonResponse() {
|
||||
let resolveResponse: ((value: JsonResponse) => void) | undefined;
|
||||
let rejectResponse: ((reason?: unknown) => void) | undefined;
|
||||
const promise = new Promise<JsonResponse>((resolve, reject) => {
|
||||
resolveResponse = resolve;
|
||||
rejectResponse = reject;
|
||||
});
|
||||
|
||||
if (!resolveResponse || !rejectResponse) {
|
||||
throw new Error('Deferred response handlers were not initialized');
|
||||
}
|
||||
|
||||
return { promise, resolve: resolveResponse, reject: rejectResponse };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
@@ -282,6 +297,147 @@ test('useListViewResource: fetchData sets loading to true then false', async ()
|
||||
});
|
||||
});
|
||||
|
||||
test('useListViewResource: ignores an older response that resolves last', async () => {
|
||||
const older = deferredJsonResponse();
|
||||
const newer = deferredJsonResponse();
|
||||
const toISOString = jest
|
||||
.spyOn(Date.prototype, 'toISOString')
|
||||
.mockReturnValueOnce('newer-response-time')
|
||||
.mockReturnValueOnce('older-response-time');
|
||||
jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockReturnValueOnce(older.promise)
|
||||
.mockReturnValueOnce(newer.promise);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListViewResource('chart', 'Charts', jest.fn(), false),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.fetchData({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
sortBy: [{ id: 'name' }],
|
||||
filters: [],
|
||||
});
|
||||
result.current.fetchData({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
sortBy: [{ id: 'name' }],
|
||||
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
newer.resolve({
|
||||
json: { result: [], count: 0 },
|
||||
} as unknown as JsonResponse);
|
||||
});
|
||||
expect(result.current.state.resourceCollection).toEqual([]);
|
||||
expect(result.current.state.resourceCount).toBe(0);
|
||||
expect(result.current.state.lastFetched).toBe('newer-response-time');
|
||||
|
||||
await act(async () => {
|
||||
older.resolve({
|
||||
json: { result: [{ id: 1 }, { id: 2 }], count: 2 },
|
||||
} as unknown as JsonResponse);
|
||||
});
|
||||
|
||||
expect(result.current.state.resourceCollection).toEqual([]);
|
||||
expect(result.current.state.resourceCount).toBe(0);
|
||||
expect(result.current.state.lastFetched).toBe('newer-response-time');
|
||||
expect(toISOString).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('useListViewResource: stale completion keeps the latest request loading', async () => {
|
||||
const older = deferredJsonResponse();
|
||||
const newer = deferredJsonResponse();
|
||||
jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockReturnValueOnce(older.promise)
|
||||
.mockReturnValueOnce(newer.promise);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListViewResource('chart', 'Charts', jest.fn(), false),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.fetchData({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
sortBy: [{ id: 'name' }],
|
||||
filters: [],
|
||||
});
|
||||
result.current.fetchData({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
sortBy: [{ id: 'name' }],
|
||||
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
older.resolve({
|
||||
json: { result: [{ id: 1 }], count: 1 },
|
||||
} as unknown as JsonResponse);
|
||||
});
|
||||
|
||||
expect(result.current.state.resourceCollection).toEqual([]);
|
||||
expect(result.current.state.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
newer.resolve({
|
||||
json: { result: [{ id: 2 }], count: 1 },
|
||||
} as unknown as JsonResponse);
|
||||
});
|
||||
|
||||
expect(result.current.state.resourceCollection).toEqual([{ id: 2 }]);
|
||||
expect(result.current.state.loading).toBe(false);
|
||||
});
|
||||
|
||||
test('useListViewResource: only the latest request reports an error', async () => {
|
||||
const older = deferredJsonResponse();
|
||||
const newer = deferredJsonResponse();
|
||||
const handleErrorMsg = jest.fn();
|
||||
jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockReturnValueOnce(older.promise)
|
||||
.mockReturnValueOnce(newer.promise);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListViewResource('chart', 'Charts', handleErrorMsg, false),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.fetchData({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
sortBy: [{ id: 'name' }],
|
||||
filters: [],
|
||||
});
|
||||
result.current.fetchData({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
sortBy: [{ id: 'name' }],
|
||||
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
older.reject('older request failed');
|
||||
});
|
||||
|
||||
expect(handleErrorMsg).not.toHaveBeenCalled();
|
||||
expect(result.current.state.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
newer.reject('newer request failed');
|
||||
});
|
||||
|
||||
expect(handleErrorMsg).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.state.loading).toBe(false);
|
||||
});
|
||||
|
||||
test('useListViewResource: refreshData re-fetches with last config', async () => {
|
||||
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: { result: [], count: 0 },
|
||||
|
||||
@@ -148,6 +148,7 @@ export function useListViewResource<D extends object = any>(
|
||||
);
|
||||
|
||||
const lastFetchDataConfigRef = useRef<FetchDataConfig | null>(null);
|
||||
const latestRequestIdRef = useRef(0);
|
||||
|
||||
const fetchData = useCallback(
|
||||
({
|
||||
@@ -156,6 +157,9 @@ export function useListViewResource<D extends object = any>(
|
||||
sortBy,
|
||||
filters: filterValues,
|
||||
}: FetchDataConfig) => {
|
||||
const requestId = latestRequestIdRef.current + 1;
|
||||
latestRequestIdRef.current = requestId;
|
||||
const isLatest = () => latestRequestIdRef.current === requestId;
|
||||
const config: FetchDataConfig = {
|
||||
filters: filterValues,
|
||||
pageIndex,
|
||||
@@ -196,24 +200,31 @@ export function useListViewResource<D extends object = any>(
|
||||
})
|
||||
.then(
|
||||
({ json = {} }) => {
|
||||
if (!isLatest()) {
|
||||
return;
|
||||
}
|
||||
updateState({
|
||||
collection: json.result,
|
||||
count: json.count,
|
||||
lastFetched: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
handleErrorMsg(
|
||||
t(
|
||||
'An error occurred while fetching %ss: %s',
|
||||
resourceLabel,
|
||||
errMsg,
|
||||
),
|
||||
),
|
||||
),
|
||||
createErrorHandler(errMsg => {
|
||||
if (isLatest()) {
|
||||
handleErrorMsg(
|
||||
t(
|
||||
'An error occurred while fetching %ss: %s',
|
||||
resourceLabel,
|
||||
errMsg,
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.finally(() => {
|
||||
updateState({ loading: false });
|
||||
if (isLatest()) {
|
||||
updateState({ loading: false });
|
||||
}
|
||||
});
|
||||
},
|
||||
[
|
||||
|
||||
@@ -427,7 +427,7 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: (f"{self.__class__.__name__}.deck_layers"),
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.deck_layers",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def deck_layers(self, pk: int) -> Response:
|
||||
@@ -691,13 +691,17 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
|
||||
normalization_changes: object = item.pop("normalization_changes", None)
|
||||
|
||||
# Live version identifiers before the update (empty + query-free when
|
||||
# ``ENABLE_VERSIONING_CAPTURE`` is off, so this stays inert under the
|
||||
# kill-switch).
|
||||
old_info = current_entity_version_info(Slice, pk)
|
||||
|
||||
try:
|
||||
changed_model = UpdateChartCommand(pk, item).run()
|
||||
changed_model = UpdateChartCommand(
|
||||
pk, item, normalization_changes=normalization_changes
|
||||
).run()
|
||||
new_info = current_entity_version_info(
|
||||
Slice, changed_model.id, changed_model.uuid
|
||||
)
|
||||
|
||||
@@ -368,6 +368,30 @@ class ChartPutSchema(Schema):
|
||||
external_url = fields.String(allow_none=True, validate=utils.validate_external_url)
|
||||
tags = fields.List(fields.Integer(metadata={"description": tags_description}))
|
||||
uuid = fields.UUID(allow_none=True)
|
||||
normalization_changes: fields.Raw = fields.Raw(
|
||||
load_only=True,
|
||||
allow_none=True,
|
||||
metadata={
|
||||
"description": (
|
||||
"Optional advisory Explore hydration transitions used only to "
|
||||
"remove exact automatic normalization changes from human-readable "
|
||||
"version history. Invalid metadata is ignored."
|
||||
),
|
||||
"type": "array",
|
||||
"maxItems": 256,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["control", "from_present", "to_present"],
|
||||
"properties": {
|
||||
"control": {"type": "string", "maxLength": 256},
|
||||
"from_present": {"type": "boolean"},
|
||||
"from_value": {},
|
||||
"to_present": {"type": "boolean"},
|
||||
"to_value": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ChartGetDatasourceObjectDataResponseSchema(Schema):
|
||||
|
||||
@@ -44,11 +44,15 @@ from superset.commands.utils import (
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.extensions import db
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.tags.models import ObjectType
|
||||
from superset.utils import json
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
from superset.versioning.changes.normalization import (
|
||||
register_matching_normalization_context,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,10 +64,16 @@ def is_query_context_update(properties: dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
def __init__(self, model_id: int, data: dict[str, Any]):
|
||||
self._model_id = model_id
|
||||
self._properties = data.copy()
|
||||
def __init__(
|
||||
self,
|
||||
model_id: int,
|
||||
data: dict[str, Any],
|
||||
normalization_changes: object = None,
|
||||
) -> None:
|
||||
self._model_id: int = model_id
|
||||
self._properties: dict[str, Any] = data.copy()
|
||||
self._model: Optional[Slice] = None
|
||||
self._normalization_changes: object = normalization_changes
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=ChartUpdateFailedError))
|
||||
def run(self) -> Model:
|
||||
@@ -78,6 +88,15 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
self._properties["last_saved_at"] = datetime.now()
|
||||
self._properties["last_saved_by"] = g.user
|
||||
|
||||
if self._normalization_changes is not None and "params" in self._properties:
|
||||
register_matching_normalization_context(
|
||||
db.session,
|
||||
self._model.id,
|
||||
self._normalization_changes,
|
||||
self._model.params,
|
||||
self._properties["params"],
|
||||
)
|
||||
|
||||
return ChartDAO.update(self._model, self._properties)
|
||||
|
||||
def _validate_new_dashboard_access(
|
||||
|
||||
@@ -33,11 +33,7 @@ from superset.commands.dataset.exceptions import (
|
||||
)
|
||||
from superset.commands.utils import populate_subjects
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
from superset.exceptions import (
|
||||
SupersetException,
|
||||
SupersetParseError,
|
||||
SupersetSecurityException,
|
||||
)
|
||||
from superset.exceptions import SupersetParseError, SupersetSecurityException
|
||||
from superset.extensions import security_manager
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
@@ -54,25 +50,7 @@ class CreateDatasetCommand(CreateMixin, BaseCommand):
|
||||
self.validate()
|
||||
|
||||
dataset = DatasetDAO.create(attributes=self._properties)
|
||||
try:
|
||||
dataset.fetch_metadata()
|
||||
except SupersetException as ex:
|
||||
# Not a SQLAlchemyError, so ``on_error`` re-raises it untouched and
|
||||
# it escapes to FAB's ``@safe`` as an opaque 500 "Fatal error".
|
||||
# Deliberately covers the 403 ``SupersetSecurityException`` raised
|
||||
# for mutation/multi-statement SQL too: ``validate()`` already
|
||||
# reports that class of rejection as a 422 on ``sql`` via
|
||||
# ``DatasetDataAccessIsNotAllowed``.
|
||||
raise DatasetInvalidError(
|
||||
exceptions=[
|
||||
ValidationError(
|
||||
# ``lazy_gettext`` messages aren't ``str``, so
|
||||
# marshmallow won't wrap them into a list on its own.
|
||||
[str(ex.message)],
|
||||
field_name="sql" if self._properties.get("sql") else "table",
|
||||
)
|
||||
]
|
||||
) from ex
|
||||
dataset.fetch_metadata()
|
||||
return dataset
|
||||
|
||||
def validate(self) -> None: # noqa: C901
|
||||
|
||||
@@ -29,7 +29,9 @@ from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticViewCreateFailedError,
|
||||
SemanticViewForbiddenError,
|
||||
)
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
from superset.semantic_layers.registry import registry
|
||||
from superset.utils import json
|
||||
@@ -92,9 +94,13 @@ class CreateSemanticViewCommand(BaseCommand):
|
||||
|
||||
def validate(self) -> None:
|
||||
layer_uuid: str = self._properties.get("semantic_layer_uuid", "")
|
||||
if not SemanticLayerDAO.find_by_uuid(layer_uuid):
|
||||
layer = SemanticLayerDAO.find_by_uuid(layer_uuid)
|
||||
if not layer:
|
||||
raise SemanticLayerNotFoundError()
|
||||
|
||||
if not current_user_can_modify_object(layer):
|
||||
raise SemanticViewForbiddenError()
|
||||
|
||||
name: str = self._properties.get("name", "")
|
||||
configuration: dict[str, Any] = self._properties.get("configuration") or {}
|
||||
if not SemanticViewDAO.validate_uniqueness(name, layer_uuid, configuration):
|
||||
|
||||
@@ -21,17 +21,17 @@ from functools import partial
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerDeleteFailedError,
|
||||
SemanticLayerForbiddenError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticViewDeleteFailedError,
|
||||
SemanticViewForbiddenError,
|
||||
SemanticViewNotFoundError,
|
||||
)
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.semantic_layers.models import SemanticLayer, SemanticView
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
@@ -60,6 +60,9 @@ class DeleteSemanticLayerCommand(BaseCommand):
|
||||
if not self._model:
|
||||
raise SemanticLayerNotFoundError()
|
||||
|
||||
if not current_user_can_modify_object(self._model):
|
||||
raise SemanticLayerForbiddenError()
|
||||
|
||||
|
||||
class DeleteSemanticViewCommand(BaseCommand):
|
||||
def __init__(self, pk: int):
|
||||
@@ -82,10 +85,8 @@ class DeleteSemanticViewCommand(BaseCommand):
|
||||
self._model = SemanticViewDAO.find_by_id(self._pk, id_column="id")
|
||||
if not self._model:
|
||||
raise SemanticViewNotFoundError()
|
||||
try:
|
||||
security_manager.raise_for_editorship(self._model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise SemanticViewForbiddenError() from ex
|
||||
if not current_user_can_modify_object(self._model):
|
||||
raise SemanticViewForbiddenError()
|
||||
|
||||
|
||||
class BulkDeleteSemanticViewCommand(BaseCommand):
|
||||
@@ -109,7 +110,5 @@ class BulkDeleteSemanticViewCommand(BaseCommand):
|
||||
if len(self._models) != len(self._model_ids):
|
||||
raise SemanticViewNotFoundError()
|
||||
for model in self._models:
|
||||
try:
|
||||
security_manager.raise_for_editorship(model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise SemanticViewForbiddenError() from ex
|
||||
if not current_user_can_modify_object(model):
|
||||
raise SemanticViewForbiddenError()
|
||||
|
||||
@@ -23,9 +23,9 @@ from typing import Any
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerForbiddenError,
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticLayerUpdateFailedError,
|
||||
@@ -33,8 +33,8 @@ from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticViewNotFoundError,
|
||||
SemanticViewUpdateFailedError,
|
||||
)
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.semantic_layers.models import SemanticLayer, SemanticView
|
||||
from superset.semantic_layers.registry import registry
|
||||
from superset.utils import json
|
||||
@@ -66,10 +66,8 @@ class UpdateSemanticViewCommand(BaseCommand):
|
||||
if not self._model:
|
||||
raise SemanticViewNotFoundError()
|
||||
|
||||
try:
|
||||
security_manager.raise_for_editorship(self._model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise SemanticViewForbiddenError() from ex
|
||||
if not current_user_can_modify_object(self._model):
|
||||
raise SemanticViewForbiddenError()
|
||||
|
||||
name = self._properties.get("name", self._model.name)
|
||||
layer_uuid = str(self._model.semantic_layer_uuid)
|
||||
@@ -116,6 +114,9 @@ class UpdateSemanticLayerCommand(BaseCommand):
|
||||
if not self._model:
|
||||
raise SemanticLayerNotFoundError()
|
||||
|
||||
if not current_user_can_modify_object(self._model):
|
||||
raise SemanticLayerForbiddenError()
|
||||
|
||||
name = self._properties.get("name")
|
||||
if name and not SemanticLayerDAO.validate_update_uniqueness(self._uuid, name):
|
||||
raise SemanticLayerInvalidError(f"Name already exists: {name}")
|
||||
|
||||
@@ -21,11 +21,8 @@ from typing import Any
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand, CreateMixin
|
||||
from superset.commands.tag.exceptions import TagCreateFailedError, TagInvalidError
|
||||
from superset.commands.tag.utils import (
|
||||
current_user_can_modify_object,
|
||||
to_object_model,
|
||||
to_object_type,
|
||||
)
|
||||
from superset.commands.tag.utils import to_object_model, to_object_type
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
from superset.daos.tag import TagDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.tags.models import ObjectType, TagType
|
||||
|
||||
@@ -18,19 +18,22 @@ import logging
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.exceptions import TagNotFoundValidationError
|
||||
from superset.commands.tag.exceptions import (
|
||||
TagDeleteFailedError,
|
||||
TagDeleteForbiddenValidationError,
|
||||
TaggedObjectDeleteFailedError,
|
||||
TaggedObjectNotFoundError,
|
||||
TagInvalidError,
|
||||
TagNotFoundError,
|
||||
)
|
||||
from superset.commands.tag.utils import to_object_model, to_object_type
|
||||
from superset.daos.tag import TagDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.tags.models import ObjectType
|
||||
from superset.tags.models import ObjectType, TagType
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
from superset.views.base import DeleteMixin
|
||||
|
||||
@@ -134,10 +137,42 @@ class DeleteTagsCommand(DeleteMixin, BaseCommand):
|
||||
TagDAO.delete_tags(self._tags)
|
||||
|
||||
def validate(self) -> None:
|
||||
exceptions = []
|
||||
# Validate tag exists
|
||||
for tag in self._tags:
|
||||
if not TagDAO.find_by_name(tag):
|
||||
exceptions.append(TagNotFoundError(tag))
|
||||
# Every item appended here must be a ValidationError (or subclass),
|
||||
# since TagInvalidError.normalized_messages() calls
|
||||
# .normalized_messages() on each one to build the aggregated 422
|
||||
# response.
|
||||
exceptions: list[ValidationError] = []
|
||||
for tag_name in self._tags:
|
||||
tag_name = tag_name.strip()
|
||||
tag = TagDAO.find_by_name(tag_name)
|
||||
# Validate tag exists
|
||||
if not tag:
|
||||
exceptions.append(
|
||||
TagNotFoundValidationError(f"Tag with name {tag_name} not found.")
|
||||
)
|
||||
continue
|
||||
# System-generated tags (type:*, editor:*, favorited_by:*) are
|
||||
# maintained by Superset itself and must not be deletable through
|
||||
# the bulk route.
|
||||
if tag.type is not None and tag.type != TagType.custom:
|
||||
exceptions.append(
|
||||
TagDeleteForbiddenValidationError(
|
||||
f"Tag {tag_name} is a system tag and cannot be deleted"
|
||||
)
|
||||
)
|
||||
continue
|
||||
# Deleting a tag cascades removal of all of its associations
|
||||
# org-wide, so existence is not enough: require the user to be an
|
||||
# admin or the tag's creator (the single-association route
|
||||
# enforces per-object access in DeleteTaggedObjectCommand).
|
||||
if not (
|
||||
security_manager.is_admin()
|
||||
or (tag.created_by and tag.created_by == security_manager.current_user)
|
||||
):
|
||||
exceptions.append(
|
||||
TagDeleteForbiddenValidationError(
|
||||
f"Access denied to tag {tag_name}"
|
||||
)
|
||||
)
|
||||
if exceptions:
|
||||
raise TagInvalidError(exceptions=exceptions)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
from typing import Optional
|
||||
|
||||
from flask_babel import lazy_gettext as _
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.commands.exceptions import (
|
||||
CommandException,
|
||||
@@ -44,6 +45,18 @@ class TagDeleteFailedError(DeleteFailedError):
|
||||
message = _("Tag could not be deleted.")
|
||||
|
||||
|
||||
class TagDeleteForbiddenValidationError(ValidationError):
|
||||
"""A tag exists but may not be deleted (a system-generated tag, or the
|
||||
caller lacks ownership/admin rights). Unlike ``TagDeleteFailedError``,
|
||||
this is a ``ValidationError`` so it can be composited into a
|
||||
``TagInvalidError`` alongside other validation failures and still
|
||||
support ``CommandInvalidError.normalized_messages()``.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message, field_name="tags")
|
||||
|
||||
|
||||
class TaggedObjectDeleteFailedError(DeleteFailedError):
|
||||
message = _("Tagged Object could not be deleted.")
|
||||
|
||||
|
||||
@@ -22,11 +22,8 @@ from flask_appbuilder.models.sqla import Model
|
||||
from superset import db
|
||||
from superset.commands.base import BaseCommand, UpdateMixin
|
||||
from superset.commands.tag.exceptions import TagInvalidError, TagNotFoundError
|
||||
from superset.commands.tag.utils import (
|
||||
current_user_can_modify_object,
|
||||
to_object_model,
|
||||
to_object_type,
|
||||
)
|
||||
from superset.commands.tag.utils import to_object_model, to_object_type
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
from superset.daos.tag import TagDAO
|
||||
from superset.tags.models import Tag
|
||||
from superset.utils.decorators import transaction
|
||||
|
||||
@@ -17,11 +17,9 @@
|
||||
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from superset import security_manager
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.daos.query import SavedQueryDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.models.sql_lab import SavedQuery
|
||||
@@ -52,22 +50,3 @@ def to_object_model(
|
||||
|
||||
return DatasetDAO.find_by_id(object_id, skip_base_filter=skip_base_filter)
|
||||
return None
|
||||
|
||||
|
||||
def current_user_can_modify_object(model: Any) -> bool:
|
||||
"""Whether the current user may create/modify tag relationships on ``model``.
|
||||
|
||||
Mirrors the editorship check the bulk-create path already applies, or the
|
||||
object's creator, so the tag-update path enforces the same boundary.
|
||||
Look the model up with
|
||||
``skip_base_filter=True`` before calling this, so an object the user cannot
|
||||
access reaches the check instead of resolving to ``None`` and being written
|
||||
without any check.
|
||||
"""
|
||||
try:
|
||||
security_manager.raise_for_editorship(model)
|
||||
return True
|
||||
except SupersetSecurityException:
|
||||
return bool(
|
||||
model.created_by and model.created_by == security_manager.current_user
|
||||
)
|
||||
|
||||
@@ -44,6 +44,18 @@ def import_theme(config: dict[str, Any], overwrite: bool = False) -> "Theme | No
|
||||
if existing:
|
||||
if not overwrite or not can_write:
|
||||
return existing
|
||||
if existing.is_system:
|
||||
raise ThemeImportError("Cannot overwrite a system theme via import")
|
||||
# The active system-default/dark theme slot may be overwritten by
|
||||
# admins only; a non-admin overwriting it would change the theme
|
||||
# rendered for every user, including the login page and other
|
||||
# admins.
|
||||
if (
|
||||
existing.is_system_default or existing.is_system_dark
|
||||
) and not security_manager.is_admin():
|
||||
raise ThemeImportError(
|
||||
"Cannot overwrite the active system-default/dark theme via import"
|
||||
)
|
||||
config["id"] = existing.id
|
||||
elif not can_write:
|
||||
raise ThemeImportError(
|
||||
|
||||
@@ -18,8 +18,10 @@ import logging
|
||||
from functools import partial
|
||||
from typing import Any, Optional
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import UpdateMixin
|
||||
from superset.commands.theme.exceptions import (
|
||||
SystemThemeInUseError,
|
||||
SystemThemeProtectedError,
|
||||
ThemeNotFoundError,
|
||||
)
|
||||
@@ -52,3 +54,11 @@ class UpdateThemeCommand(UpdateMixin):
|
||||
# Check if it's a system theme
|
||||
if self._model.is_system:
|
||||
raise SystemThemeProtectedError()
|
||||
|
||||
# The active system-default/dark theme slot may be edited by admins
|
||||
# only; a non-admin editing it would change the theme rendered for
|
||||
# every user, including the login page and other admins.
|
||||
if (
|
||||
self._model.is_system_default or self._model.is_system_dark
|
||||
) and not security_manager.is_admin():
|
||||
raise SystemThemeInUseError()
|
||||
|
||||
@@ -32,6 +32,7 @@ from superset.commands.exceptions import (
|
||||
from superset.daos.datasource import DatasourceDAO
|
||||
from superset.daos.exceptions import DatasourceNotFound
|
||||
from superset.daos.tag import TagDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.subjects.exceptions import SubjectsNotFoundValidationError
|
||||
from superset.subjects.models import Subject
|
||||
from superset.subjects.utils import (
|
||||
@@ -52,6 +53,30 @@ def _has_extra_editors_resolver() -> bool:
|
||||
return bool(has_app_context() and current_app.config.get("EXTRA_EDITORS_RESOLVER"))
|
||||
|
||||
|
||||
def current_user_can_modify_object(model: Any) -> bool:
|
||||
"""Whether the current user is authorized to create/modify ``model``.
|
||||
|
||||
Delegates to ``security_manager.raise_for_editorship``, which grants
|
||||
access to admins and any subject in ``model.editors`` (when that
|
||||
relationship exists). For models that don't carry an ``editors``
|
||||
relationship, or when the current subject isn't one of them, this falls
|
||||
back to allowing the object's creator.
|
||||
|
||||
Callers that need to distinguish "not found" from "no access" should
|
||||
look the model up bypassing DAO base filters (e.g.
|
||||
``skip_base_filter=True``) before calling this, so an object the user
|
||||
cannot access reaches the check instead of resolving to ``None`` and
|
||||
being written without any check.
|
||||
"""
|
||||
try:
|
||||
security_manager.raise_for_editorship(model)
|
||||
return True
|
||||
except SupersetSecurityException:
|
||||
return bool(
|
||||
model.created_by and model.created_by == security_manager.current_user
|
||||
)
|
||||
|
||||
|
||||
def populate_subject_list(
|
||||
subject_ids: list[int] | None,
|
||||
default_to_user: bool,
|
||||
|
||||
@@ -196,13 +196,26 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
|
||||
# 1. 'metric_name' - name of predefined metric
|
||||
# 2. { label: 'label_name' } - legacy format for a predefined metric
|
||||
# 3. { expressionType: 'SIMPLE' | 'SQL', ... } - adhoc metric
|
||||
def is_str_or_adhoc(metric: Metric) -> bool:
|
||||
return isinstance(metric, str) or is_adhoc_metric(metric)
|
||||
# Keys that only ever appear on an ad-hoc metric definition. A dict
|
||||
# carrying one of these but missing `expressionType` is a malformed
|
||||
# ad-hoc metric, not a legacy predefined-metric reference, and must
|
||||
# not be silently collapsed to its label, which would later be
|
||||
# misread as a request for a saved metric of that name.
|
||||
adhoc_metric_keys = {"sqlExpression", "aggregate", "column"}
|
||||
|
||||
self.metrics = metrics and [
|
||||
x if is_str_or_adhoc(x) else x["label"] # type: ignore
|
||||
for x in metrics
|
||||
]
|
||||
def normalize_metric(metric: Metric) -> Metric:
|
||||
if isinstance(metric, str) or is_adhoc_metric(metric):
|
||||
return metric
|
||||
if adhoc_metric_keys & metric.keys():
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
"Invalid ad-hoc metric %(label)s: `expressionType` is missing",
|
||||
label=metric.get("label"),
|
||||
)
|
||||
)
|
||||
return metric["label"] # type: ignore
|
||||
|
||||
self.metrics = metrics and [normalize_metric(x) for x in metrics]
|
||||
|
||||
def _set_post_processing(
|
||||
self, post_processing: list[dict[str, Any] | None] | None
|
||||
|
||||
@@ -99,6 +99,7 @@ from superset.models.helpers import (
|
||||
AuditMixinNullable,
|
||||
CertificationMixin,
|
||||
ExploreMixin,
|
||||
get_effective_hours_offset,
|
||||
ImportExportMixin,
|
||||
QueryResult,
|
||||
SoftDeleteMixin,
|
||||
@@ -1247,6 +1248,8 @@ class TableColumn(AuditMixinNullable, ImportExportMixin, CertificationMixin, Mod
|
||||
time_grain: str | None,
|
||||
label: str | None = None,
|
||||
template_processor: BaseTemplateProcessor | None = None,
|
||||
apply_dataset_offset: bool = False,
|
||||
sql_shifted_temporal_labels: set[str] | None = None,
|
||||
) -> TimestampExpression | Label:
|
||||
"""
|
||||
Return a SQLAlchemy Core element representation of self to be used in a query.
|
||||
@@ -1254,6 +1257,8 @@ class TableColumn(AuditMixinNullable, ImportExportMixin, CertificationMixin, Mod
|
||||
:param time_grain: Optional time grain, e.g. P1Y
|
||||
:param label: alias/label that column is expected to have
|
||||
:param template_processor: template processor
|
||||
:param apply_dataset_offset: shift the selected axis before truncation
|
||||
:param sql_shifted_temporal_labels: labels shifted before truncation
|
||||
:return: A TimeExpression object wrapped in a Label if supported by db
|
||||
"""
|
||||
label = label or utils.DTTM_ALIAS
|
||||
@@ -1291,6 +1296,27 @@ class TableColumn(AuditMixinNullable, ImportExportMixin, CertificationMixin, Mod
|
||||
col = literal_column(expression, type_=type_)
|
||||
else:
|
||||
col = column(self.column_name, type_=type_)
|
||||
if (
|
||||
apply_dataset_offset
|
||||
and time_grain
|
||||
and self.table
|
||||
and self.db_engine_spec.supports_temporal_column_shift
|
||||
and (offset_hours := self.table.offset or 0)
|
||||
and not self.table.get_dataset_timezone()
|
||||
):
|
||||
effective_offset_hours = get_effective_hours_offset(
|
||||
self.db_engine_spec,
|
||||
self.type,
|
||||
offset_hours,
|
||||
db_extra=self.db_extra,
|
||||
)
|
||||
if effective_offset_hours:
|
||||
col = self.db_engine_spec.get_temporal_column_shift_expr(
|
||||
col,
|
||||
effective_offset_hours,
|
||||
)
|
||||
if sql_shifted_temporal_labels is not None:
|
||||
sql_shifted_temporal_labels.add(label)
|
||||
time_expr = self.db_engine_spec.get_timestamp_expr(col, pdf, time_grain)
|
||||
return self.database.make_sqla_column_compatible(time_expr, label)
|
||||
|
||||
@@ -1975,11 +2001,26 @@ class SqlaTable(
|
||||
)
|
||||
) from ex
|
||||
|
||||
def _shift_temporal_column_if_needed(
|
||||
self,
|
||||
sqla_column: ColumnClause,
|
||||
effective_offset_hours: int,
|
||||
) -> ColumnClause:
|
||||
"""Apply a nonzero effective dataset offset to a temporal expression."""
|
||||
if not effective_offset_hours:
|
||||
return sqla_column
|
||||
return self.db_engine_spec.get_temporal_column_shift_expr(
|
||||
sqla_column,
|
||||
effective_offset_hours,
|
||||
)
|
||||
|
||||
def adhoc_column_to_sqla( # pylint: disable=too-many-locals
|
||||
self,
|
||||
col: AdhocColumn,
|
||||
force_type_check: bool = False,
|
||||
template_processor: BaseTemplateProcessor | None = None,
|
||||
apply_dataset_offset: bool = False,
|
||||
sql_shifted_temporal_labels: set[str] | None = None,
|
||||
) -> tuple[ColumnElement, utils.GenericDataType | None]:
|
||||
"""
|
||||
Turn an adhoc column into a sqlalchemy column.
|
||||
@@ -1989,6 +2030,8 @@ class SqlaTable(
|
||||
This is needed to validate if a filter with an adhoc column
|
||||
is applicable.
|
||||
:param template_processor: template_processor instance
|
||||
:param apply_dataset_offset: shift the selected axis before truncation
|
||||
:param sql_shifted_temporal_labels: labels shifted before truncation
|
||||
:returns: A tuple of (SQLAlchemy column, generic column type). The
|
||||
generic type is populated when the column type is resolved
|
||||
(either because the adhoc column matches a physical column, or
|
||||
@@ -2005,6 +2048,7 @@ class SqlaTable(
|
||||
pdf = None
|
||||
is_column_reference = col.get("isColumnReference", False)
|
||||
generic_type: utils.GenericDataType | None = None
|
||||
native_type: str | None = None
|
||||
|
||||
metadata_lookup_key = self._render_adhoc_expression_for_metadata_lookup(
|
||||
sql_expression, template_processor
|
||||
@@ -2019,6 +2063,7 @@ class SqlaTable(
|
||||
is_dttm = col_in_metadata.is_temporal
|
||||
pdf = col_in_metadata.python_date_format
|
||||
generic_type = col_in_metadata.type_generic
|
||||
native_type = col_in_metadata.type
|
||||
else:
|
||||
# Column doesn't exist in metadata or is not a reference - treat as ad-hoc
|
||||
# expression Note: If isColumnReference=true but column not found, we still
|
||||
@@ -2081,8 +2126,28 @@ class SqlaTable(
|
||||
# stay unquoted for numeric adhoc expressions like
|
||||
# CAST(... AS BIGINT)).
|
||||
generic_type = col_desc[0].get("type_generic")
|
||||
probed_type = col_desc[0].get("type")
|
||||
native_type = str(probed_type) if probed_type is not None else None
|
||||
|
||||
if is_dttm and has_timegrain:
|
||||
if (
|
||||
apply_dataset_offset
|
||||
and self.db_engine_spec.supports_temporal_column_shift
|
||||
and (offset_hours := self.offset or 0)
|
||||
and not self.get_dataset_timezone()
|
||||
):
|
||||
effective_offset_hours = get_effective_hours_offset(
|
||||
self.db_engine_spec,
|
||||
native_type,
|
||||
offset_hours,
|
||||
db_extra=self.db_extra,
|
||||
)
|
||||
sqla_column = self._shift_temporal_column_if_needed(
|
||||
sqla_column,
|
||||
effective_offset_hours,
|
||||
)
|
||||
if sql_shifted_temporal_labels is not None:
|
||||
sql_shifted_temporal_labels.add(label)
|
||||
sqla_column = self.db_engine_spec.get_timestamp_expr(
|
||||
col=sqla_column,
|
||||
pdf=pdf,
|
||||
|
||||
+17
-1
@@ -21,7 +21,7 @@ from flask import g
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
|
||||
from superset.commands.tag.exceptions import TagNotFoundError
|
||||
from superset.commands.tag.utils import to_object_type
|
||||
from superset.commands.tag.utils import to_object_model, to_object_type
|
||||
from superset.daos.base import BaseDAO
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
@@ -345,6 +345,10 @@ class TagDAO(BaseDAO[Tag]):
|
||||
Returns:
|
||||
None.
|
||||
"""
|
||||
# Imported lazily: superset.commands.utils itself imports TagDAO from
|
||||
# this module, so a top-level import here would be circular.
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
|
||||
tagged_objects = []
|
||||
if not tag:
|
||||
raise TagNotFoundError()
|
||||
@@ -372,6 +376,18 @@ class TagDAO(BaseDAO[Tag]):
|
||||
if not bulk_create:
|
||||
# delete relationships that aren't retained from single tag create
|
||||
for object_type, object_id in tagged_objects_to_delete:
|
||||
# Only remove associations from objects the current user may
|
||||
# modify, mirroring the per-object check applied to additions.
|
||||
# Look the object up bypassing the access base filter so an
|
||||
# inaccessible object reaches the check instead of resolving
|
||||
# to None and having its association deleted unchecked.
|
||||
model = to_object_model(
|
||||
object_type, # type: ignore
|
||||
object_id,
|
||||
skip_base_filter=True,
|
||||
)
|
||||
if model and not current_user_can_modify_object(model):
|
||||
continue
|
||||
# delete objects that were removed
|
||||
TagDAO.delete_tagged_object(
|
||||
object_type, # type: ignore
|
||||
|
||||
@@ -1316,6 +1316,10 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
|
||||
try:
|
||||
TestConnectionDatabaseCommand(item).run()
|
||||
return self.response(200, message="OK")
|
||||
except OAuth2RedirectError:
|
||||
# OAuth2 connections pass, so they can be saved. A user later
|
||||
# can then store an OAuth2 token.
|
||||
return self.response(200, message="OK")
|
||||
except (
|
||||
SSHTunnelingNotEnabledError,
|
||||
SSHTunnelDatabasePortError,
|
||||
|
||||
@@ -34,7 +34,6 @@ from superset import security_manager
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.exceptions import SupersetMarshmallowValidationError
|
||||
from superset.models.sql_types import parse_currency_string
|
||||
from superset.subjects.schemas import SubjectResponseSchema
|
||||
from superset.utils import json
|
||||
|
||||
get_delete_ids_schema = {
|
||||
@@ -480,16 +479,32 @@ class DatasetColumnDrillInfoSchema(Schema):
|
||||
|
||||
|
||||
class UserSchema(Schema):
|
||||
# Deliberately excludes ``email``: drill_info is reachable by any user
|
||||
# with read access to the dataset (and, via the dashboard fallback, by
|
||||
# embedded guests), so exposing maintainer emails here would leak user
|
||||
# PII across an access boundary. Mirrors the dashboard/RLS user schemas,
|
||||
# which expose names only.
|
||||
first_name = fields.String()
|
||||
last_name = fields.String()
|
||||
email = fields.String()
|
||||
|
||||
|
||||
class DrillInfoEditorSchema(Schema):
|
||||
# Deliberately excludes ``secondary_label``: for a user-backed Subject,
|
||||
# user-subject synchronization (superset.subjects.sync.sync_user_subject)
|
||||
# stores that user's email in this field, so including it here would
|
||||
# leak the same maintainer PII that ``UserSchema`` above excludes
|
||||
# ``email`` to avoid, just through a different field name.
|
||||
id = fields.Int()
|
||||
label = fields.String()
|
||||
img = fields.String()
|
||||
type = fields.Integer()
|
||||
|
||||
|
||||
class DatasetDrillInfoSchema(Schema):
|
||||
id = fields.Integer()
|
||||
columns = fields.List(fields.Nested(DatasetColumnDrillInfoSchema))
|
||||
table_name = fields.String()
|
||||
editors = fields.List(fields.Nested(SubjectResponseSchema))
|
||||
editors = fields.List(fields.Nested(DrillInfoEditorSchema))
|
||||
created_by = fields.Nested(UserSchema)
|
||||
created_on_humanized = fields.String()
|
||||
changed_by = fields.Nested(UserSchema)
|
||||
|
||||
@@ -539,6 +539,7 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
# the ``array_*`` capability methods below must be implemented. Defaults to
|
||||
# False so engines that have not opted in keep treating arrays as strings.
|
||||
supports_multivalue_columns = False
|
||||
supports_temporal_column_shift: bool = False
|
||||
allows_joins = True
|
||||
allows_subqueries = True
|
||||
allows_alias_in_select = True
|
||||
@@ -1242,6 +1243,19 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
|
||||
return TimestampExpression(time_expr, col, type_=col.type)
|
||||
|
||||
@classmethod
|
||||
def get_temporal_column_shift_expr(
|
||||
cls,
|
||||
col: ColumnClause,
|
||||
offset_hours: int,
|
||||
) -> TimestampExpression:
|
||||
"""Shift a temporal SQL expression by a bounded number of hours."""
|
||||
return TimestampExpression(
|
||||
f"{{col}} + INTERVAL '{offset_hours}' HOUR",
|
||||
col,
|
||||
type_=col.type,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _apply_year_to_dttm(cls, time_expr: str) -> str:
|
||||
"""
|
||||
@@ -1378,12 +1392,12 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
return cursor.fetchmany(limit)
|
||||
data = cursor.fetchall()
|
||||
description = cursor.description or []
|
||||
# Create a mapping between column name and a mutator function to normalize
|
||||
# values with. The first two items in the description row are
|
||||
# the column name and type.
|
||||
# Create a mapping between column index and a mutator function to normalize
|
||||
# values with. The first two items in the description row are the column
|
||||
# name and type.
|
||||
column_mutators = {
|
||||
row[0]: func
|
||||
for row in description
|
||||
index: func
|
||||
for index, row in enumerate(description)
|
||||
if (
|
||||
func := cls.column_type_mutators.get(
|
||||
type(cls.get_sqla_column_type(cls.get_datatype(row[1])))
|
||||
@@ -1391,11 +1405,11 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
)
|
||||
}
|
||||
if column_mutators:
|
||||
indexes = {row[0]: idx for idx, row in enumerate(description)}
|
||||
if not isinstance(data, list):
|
||||
data = list(data)
|
||||
for row_idx, row in enumerate(data):
|
||||
new_row = list(row)
|
||||
for col, func in column_mutators.items():
|
||||
col_idx = indexes[col]
|
||||
for col_idx, func in column_mutators.items():
|
||||
new_row[col_idx] = func(row[col_idx])
|
||||
data[row_idx] = tuple(new_row)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from re import Pattern
|
||||
from typing import Any, TYPE_CHECKING, TypedDict
|
||||
|
||||
@@ -32,7 +33,7 @@ from marshmallow.exceptions import ValidationError
|
||||
from requests import Session
|
||||
from shillelagh.adapters.api.gsheets.lib import SCOPES
|
||||
from shillelagh.exceptions import UnauthenticatedError
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import text, types
|
||||
from sqlalchemy.engine import create_engine
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.engine.url import URL
|
||||
@@ -155,6 +156,27 @@ class GSheetsEngineSpec(ShillelaghEngineSpec):
|
||||
oauth2_token_request_uri = "https://oauth2.googleapis.com/token" # noqa: S105
|
||||
oauth2_exception = (UnauthenticatedError, OAuth2TokenRefreshError)
|
||||
|
||||
@classmethod
|
||||
def convert_dttm(
|
||||
cls, target_type: str, dttm: datetime, db_extra: dict[str, Any] | None = None
|
||||
) -> str | None:
|
||||
"""
|
||||
Convert a datetime to a SQL literal understood by shillelagh's GSheets
|
||||
adapter.
|
||||
|
||||
``SqliteEngineSpec.convert_dttm`` (inherited via ``ShillelaghEngineSpec``)
|
||||
has no case for ``types.Date`` and returns ``None``, which makes Superset
|
||||
fall back to a literal that still carries a time-of-day component. The
|
||||
GSheets adapter's virtual table layer parses that literal with
|
||||
``datetime.date.fromisoformat``, which rejects the trailing time and
|
||||
silently drops the filter value, producing an invalid query against the
|
||||
Google Sheets API. A bare ``YYYY-MM-DD`` literal is required instead.
|
||||
"""
|
||||
sqla_type = cls.get_sqla_column_type(target_type)
|
||||
if isinstance(sqla_type, types.Date):
|
||||
return f"'{dttm.date().isoformat()}'"
|
||||
return super().convert_dttm(target_type, dttm, db_extra=db_extra)
|
||||
|
||||
@classmethod
|
||||
def get_oauth2_authorization_uri(
|
||||
cls,
|
||||
|
||||
@@ -267,6 +267,43 @@ class MySQLEngineSpec(BasicParametersMixin, BaseEngineSpec):
|
||||
types.VARCHAR(),
|
||||
GenericDataType.STRING,
|
||||
),
|
||||
# wire-protocol FIELD_TYPE names emitted by `get_datatype`, seen on
|
||||
# SQL Lab and virtual dataset columns instead of DDL type names
|
||||
(
|
||||
re.compile(r"^newdecimal", re.IGNORECASE),
|
||||
DECIMAL(),
|
||||
GenericDataType.NUMERIC,
|
||||
),
|
||||
(
|
||||
re.compile(r"^tiny$", re.IGNORECASE),
|
||||
TINYINT(),
|
||||
GenericDataType.NUMERIC,
|
||||
),
|
||||
(
|
||||
re.compile(r"^short$", re.IGNORECASE),
|
||||
types.SmallInteger(),
|
||||
GenericDataType.NUMERIC,
|
||||
),
|
||||
(
|
||||
re.compile(r"^(blob|text)$", re.IGNORECASE),
|
||||
types.String(),
|
||||
GenericDataType.STRING,
|
||||
),
|
||||
(
|
||||
re.compile(r"^year$", re.IGNORECASE),
|
||||
types.Integer(),
|
||||
GenericDataType.NUMERIC,
|
||||
),
|
||||
(
|
||||
re.compile(r"^enum\b", re.IGNORECASE),
|
||||
types.String(),
|
||||
GenericDataType.STRING,
|
||||
),
|
||||
(
|
||||
re.compile(r"^set\b", re.IGNORECASE),
|
||||
types.String(),
|
||||
GenericDataType.STRING,
|
||||
),
|
||||
)
|
||||
column_type_mutators: dict[types.TypeEngine, Callable[[Any], Any]] = {
|
||||
DECIMAL: lambda val: Decimal(val) if isinstance(val, str) else val
|
||||
@@ -425,22 +462,27 @@ class MySQLEngineSpec(BasicParametersMixin, BaseEngineSpec):
|
||||
|
||||
@classmethod
|
||||
def get_datatype(cls, type_code: Any) -> Optional[str]:
|
||||
if not cls.type_code_map:
|
||||
# only import and store if needed at least once
|
||||
# pylint: disable=import-outside-toplevel
|
||||
try:
|
||||
import MySQLdb
|
||||
|
||||
mysql_module = MySQLdb
|
||||
except ImportError:
|
||||
mysql_module = __import__("pymysql")
|
||||
|
||||
ft = mysql_module.constants.FIELD_TYPE
|
||||
cls.type_code_map = {
|
||||
getattr(ft, k): k for k in dir(ft) if not k.startswith("_")
|
||||
}
|
||||
datatype = type_code
|
||||
if isinstance(type_code, int):
|
||||
if not cls.type_code_map:
|
||||
# only import and store if needed at least once
|
||||
# pylint: disable=import-outside-toplevel
|
||||
try:
|
||||
import MySQLdb
|
||||
|
||||
ft = MySQLdb.constants.FIELD_TYPE
|
||||
except ImportError:
|
||||
try:
|
||||
import pymysql # type: ignore[import-untyped]
|
||||
|
||||
ft = pymysql.constants.FIELD_TYPE
|
||||
except ImportError:
|
||||
from mysql.connector.constants import FieldType
|
||||
|
||||
ft = FieldType
|
||||
cls.type_code_map = {
|
||||
getattr(ft, k): k for k in dir(ft) if not k.startswith("_")
|
||||
}
|
||||
datatype = cls.type_code_map.get(type_code)
|
||||
if datatype and isinstance(datatype, str) and datatype:
|
||||
return datatype
|
||||
|
||||
@@ -360,6 +360,7 @@ class PostgresEngineSpec(BasicParametersMixin, PostgresBaseEngineSpec):
|
||||
supports_catalog = True
|
||||
supports_dynamic_catalog = True
|
||||
supports_grouping_sets = True
|
||||
supports_temporal_column_shift = True
|
||||
|
||||
default_driver = "psycopg2"
|
||||
parameters_schema = PostgresParametersSchema()
|
||||
|
||||
@@ -25,9 +25,14 @@ from typing import Any, TYPE_CHECKING
|
||||
from flask_babel import gettext as __
|
||||
from sqlalchemy import types
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.sql.elements import ColumnClause
|
||||
|
||||
from superset.constants import TimeGrain
|
||||
from superset.db_engine_specs.base import BaseEngineSpec, DatabaseCategory
|
||||
from superset.db_engine_specs.base import (
|
||||
BaseEngineSpec,
|
||||
DatabaseCategory,
|
||||
TimestampExpression,
|
||||
)
|
||||
from superset.errors import SupersetErrorType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -43,6 +48,7 @@ class SqliteEngineSpec(BaseEngineSpec):
|
||||
|
||||
disable_ssh_tunneling = True
|
||||
supports_multivalues_insert = True
|
||||
supports_temporal_column_shift = True
|
||||
|
||||
metadata = {
|
||||
"description": "SQLite is a self-contained, serverless SQL database engine.",
|
||||
@@ -140,6 +146,20 @@ class SqliteEngineSpec(BaseEngineSpec):
|
||||
"ELSE printf('%04d-01-01', CAST({col} AS INTEGER)) END)"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_temporal_column_shift_expr(
|
||||
cls,
|
||||
col: ColumnClause,
|
||||
offset_hours: int,
|
||||
) -> TimestampExpression:
|
||||
"""Shift a temporal expression with SQLite's datetime modifier syntax."""
|
||||
modifier = f"{offset_hours:+d} hours"
|
||||
return TimestampExpression(
|
||||
f"DATETIME({{col}}, '{modifier}')",
|
||||
col,
|
||||
type_=col.type,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def convert_dttm(
|
||||
cls, target_type: str, dttm: datetime, db_extra: dict[str, Any] | None = None
|
||||
|
||||
@@ -672,21 +672,34 @@ kubectl get ingress -n superset
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `MCP_DEV_USERNAME` | Superset username for MCP authentication | `admin` |
|
||||
| `MCP_AUTH_ENABLED` | Enable/disable authentication | `true` |
|
||||
| `MCP_DEV_USERNAME` | Superset username for MCP authentication in dev mode. Mutually exclusive with `MCP_AUTH_ENABLED = True`: the server refuses to start if both are set. | - |
|
||||
| `MCP_AUTH_ENABLED` | Enable/disable authentication | `false` |
|
||||
| `MCP_JWT_PUBLIC_KEY` | JWT public key for token validation | - |
|
||||
| `SUPERSET_WEBSERVER_ADDRESS` | Internal Superset URL | `http://localhost:8088` |
|
||||
| `WEBDRIVER_BASEURL` | URL for screenshot generation | Same as webserver |
|
||||
|
||||
#### superset_config.py Options
|
||||
|
||||
Dev mode (`MCP_DEV_USERNAME`) and JWT authentication (`MCP_AUTH_ENABLED`) are
|
||||
mutually exclusive -- the server raises at startup if both are set, since a
|
||||
fixed dev-mode identity would defeat the point of requiring real auth. Pick one:
|
||||
|
||||
```python
|
||||
# MCP Service Configuration
|
||||
# MCP Service Configuration -- development/testing (no auth)
|
||||
MCP_DEV_USERNAME = 'admin' # Username for development/testing
|
||||
|
||||
# WebDriver for chart screenshots
|
||||
WEBDRIVER_BASEURL = 'http://superset:8088/'
|
||||
WEBDRIVER_TYPE = 'chrome'
|
||||
WEBDRIVER_OPTION_ARGS = ['--headless', '--no-sandbox']
|
||||
```
|
||||
|
||||
```python
|
||||
# MCP Service Configuration -- production with JWT authentication
|
||||
MCP_AUTH_ENABLED = True # Enable authentication
|
||||
MCP_JWT_PUBLIC_KEY = 'your-public-key' # For JWT token validation
|
||||
|
||||
# For production with JWT authentication
|
||||
# Or, for a fully custom auth setup instead of the built-in JWT verifier:
|
||||
MCP_AUTH_FACTORY = 'your.custom.auth_factory'
|
||||
MCP_USER_RESOLVER = 'your.custom.user_resolver'
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ if os.environ.get("FASTMCP_TRANSPORT", "stdio") == "stdio":
|
||||
click.secho = secho_to_stderr
|
||||
|
||||
from superset.mcp_service.app import init_fastmcp_server, mcp
|
||||
from superset.mcp_service.caching import create_response_caching_middleware
|
||||
from superset.mcp_service.middleware import create_response_size_guard_middleware
|
||||
from superset.mcp_service.server import build_middleware_list
|
||||
|
||||
@@ -67,8 +68,9 @@ def _add_default_middlewares() -> None:
|
||||
|
||||
Delegates to ``server.build_middleware_list()`` for the core stack so
|
||||
the stdio entry point stays in sync with the HTTP server without
|
||||
duplicating middleware ordering. The optional response size guard is
|
||||
appended separately (innermost position, same as in run_server()).
|
||||
duplicating middleware ordering. The optional response size guard and
|
||||
response caching middleware are appended separately (innermost
|
||||
position, same order as in run_server()).
|
||||
|
||||
FastMCP wraps handlers so that the FIRST-added middleware is outermost.
|
||||
``build_middleware_list()`` already returns middlewares in the correct
|
||||
@@ -77,12 +79,16 @@ def _add_default_middlewares() -> None:
|
||||
for middleware in build_middleware_list():
|
||||
mcp.add_middleware(middleware)
|
||||
|
||||
# Response size guard is innermost (added last)
|
||||
# Response size guard is innermost (added last), then response caching.
|
||||
if size_guard := create_response_size_guard_middleware():
|
||||
mcp.add_middleware(size_guard)
|
||||
limit = size_guard.token_limit
|
||||
sys.stderr.write(f"[MCP] Response size guard enabled (token_limit={limit})\n")
|
||||
|
||||
if caching_middleware := create_response_caching_middleware():
|
||||
mcp.add_middleware(caching_middleware)
|
||||
sys.stderr.write("[MCP] Response caching enabled\n")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
@@ -157,8 +163,19 @@ def main() -> None:
|
||||
sys.stderr.write(f"[MCP] Client disconnected: {e}\n")
|
||||
sys.exit(0)
|
||||
else:
|
||||
# For other transports, use normal initialization
|
||||
init_fastmcp_server()
|
||||
# For other transports (network listeners), install the same auth
|
||||
# provider as the supported entry point (`superset mcp run` ->
|
||||
# server.run_server()) instead of starting with no verifier at all.
|
||||
# _create_auth_provider fails closed (raises MCPAuthConfigError) when
|
||||
# auth is configured but a verifier could not be built, so letting
|
||||
# that propagate here refuses to start rather than silently running
|
||||
# this transport unauthenticated.
|
||||
from superset.mcp_service.flask_singleton import get_flask_app
|
||||
from superset.mcp_service.server import _create_auth_provider
|
||||
|
||||
flask_app = get_flask_app()
|
||||
auth_provider = _create_auth_provider(flask_app)
|
||||
init_fastmcp_server(auth=auth_provider)
|
||||
_add_default_middlewares()
|
||||
|
||||
# Run with specified transport
|
||||
|
||||
@@ -575,7 +575,8 @@ def _resolve_user_from_jwt_context(app: Any) -> MCPUser | None: # noqa: C901
|
||||
the corresponding ``GuestUser`` built from the token's resources/RLS.
|
||||
|
||||
Raises:
|
||||
ValueError: If JWT resolves a username that doesn't exist in the DB
|
||||
ValueError: If JWT resolves a username that doesn't exist in the DB,
|
||||
or a guest-marked token is presented while guest auth is disabled
|
||||
(fail closed — do NOT fall through to weaker auth sources).
|
||||
MCPAuthConfigError: If more than one JWT issuer is trusted
|
||||
(``MCP_JWT_ISSUER`` is a list/tuple/set) and no issuer-aware
|
||||
@@ -618,7 +619,14 @@ def _resolve_user_from_jwt_context(app: Any) -> MCPUser | None: # noqa: C901
|
||||
"Guest-marked token presented but embedded guest auth is not "
|
||||
"enabled; rejecting"
|
||||
)
|
||||
return None
|
||||
# Fail closed, matching the sibling failure branches below: a
|
||||
# guest-marked token is an explicit (rejected) authentication
|
||||
# attempt, not an absent one. Returning None here would let the
|
||||
# request degrade to weaker auth sources (API key,
|
||||
# MCP_DEV_USERNAME, or a middleware-set g.user).
|
||||
raise ValueError(
|
||||
"Guest-marked token presented but embedded guest auth is not enabled"
|
||||
)
|
||||
logger.debug("Resolving MCP request as embedded guest user")
|
||||
# Drop the internal marker so it does not leak into GuestUser.guest_token.
|
||||
guest_claims: dict[str, Any] = {
|
||||
|
||||
@@ -123,6 +123,27 @@ def create_response_caching_middleware() -> Any | None:
|
||||
logger.debug("MCP response caching disabled")
|
||||
return None
|
||||
|
||||
# ResponseCachingMiddleware keys cache entries on the method/tool
|
||||
# name + arguments only and runs ahead of the per-request auth/RBAC
|
||||
# checks, so a cache hit returns a response computed for a different
|
||||
# caller. Only appropriate when every request is guaranteed to come
|
||||
# from the same principal.
|
||||
# that sends byte-identical arguments within the TTL, skipping every
|
||||
# authorization check. Fail closed unless the operator explicitly
|
||||
# accepts a cache shared across principals -- only safe when every
|
||||
# request is guaranteed to come from the same principal (e.g. a
|
||||
# single-user development deployment).
|
||||
if not cache_config.get("dangerously_share_cache_across_principals", False):
|
||||
logger.warning(
|
||||
"MCP_CACHE_CONFIG['enabled'] is set, but response caching "
|
||||
"stays disabled: cache keys do not include the requesting "
|
||||
"principal, so cached responses would be served across users "
|
||||
"without any authorization checks. Set "
|
||||
"'dangerously_share_cache_across_principals': True only when "
|
||||
"all requests share a single principal."
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
from fastmcp.server.middleware.caching import ResponseCachingMiddleware
|
||||
except ImportError:
|
||||
|
||||
@@ -116,6 +116,53 @@ async def restore_chart(
|
||||
return RestoreChartResponse(success=False, error=msg, error_type="NotFound")
|
||||
|
||||
chart_id = chart.id
|
||||
|
||||
# The lookup above deliberately bypasses the RBAC base filter (see
|
||||
# _find_chart_for_restore), so enforce the restore audience *before*
|
||||
# composing any response that embeds the chart's name: without this gate,
|
||||
# iterating identifiers would disclose the existence and exact title of
|
||||
# charts the caller cannot see (the web API answers 404 for those).
|
||||
from superset import security_manager
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
try:
|
||||
try:
|
||||
security_manager.raise_for_editorship(chart)
|
||||
except SupersetSecurityException:
|
||||
from superset.daos.chart import ChartDAO
|
||||
|
||||
# Distinguish "visible but not an editor" from "outside the
|
||||
# caller's RBAC scope": the latter must be indistinguishable
|
||||
# from a chart that does not exist.
|
||||
visible = ChartDAO.find_by_id_or_uuid(
|
||||
str(request.identifier), skip_visibility_filter=True
|
||||
)
|
||||
if visible is None:
|
||||
display_id = str(request.identifier)[:200]
|
||||
return RestoreChartResponse(
|
||||
success=False,
|
||||
error=f"No chart found with identifier: {display_id}.",
|
||||
error_type="NotFound",
|
||||
)
|
||||
await ctx.warning("Permission denied restoring chart id=%s" % (chart_id,))
|
||||
return RestoreChartResponse(
|
||||
success=False,
|
||||
permission_denied=True,
|
||||
error=(
|
||||
f"You do not have permission to restore chart id={chart_id}. "
|
||||
"Ask the user to restore it or grant access; do not retry."
|
||||
),
|
||||
error_type="Forbidden",
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
_rollback()
|
||||
logger.exception("Editorship check failed during restore_chart")
|
||||
return RestoreChartResponse(
|
||||
success=False,
|
||||
error="Chart lookup failed due to a database error.",
|
||||
error_type="LookupFailed",
|
||||
)
|
||||
|
||||
# Chart names are user-controlled and must remain exact in response text.
|
||||
chart_name = chart.slice_name
|
||||
|
||||
|
||||
@@ -118,6 +118,56 @@ async def restore_dashboard(
|
||||
return RestoreDashboardResponse(success=False, error=msg, error_type="NotFound")
|
||||
|
||||
dashboard_id = dashboard.id
|
||||
|
||||
# The lookup above deliberately bypasses the RBAC base filter (see
|
||||
# _find_dashboard_for_restore), so enforce the restore audience *before*
|
||||
# composing any response that embeds the dashboard's title: without this
|
||||
# gate, iterating identifiers would disclose the existence and exact title
|
||||
# of dashboards the caller cannot see (the web API answers 404 for those).
|
||||
from superset import security_manager
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
try:
|
||||
try:
|
||||
security_manager.raise_for_editorship(dashboard)
|
||||
except SupersetSecurityException:
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
|
||||
# Distinguish "visible but not an editor" from "outside the
|
||||
# caller's RBAC scope": the latter must be indistinguishable
|
||||
# from a dashboard that does not exist.
|
||||
visible = DashboardDAO.find_by_id_or_uuid(
|
||||
str(request.identifier), skip_visibility_filter=True
|
||||
)
|
||||
if visible is None:
|
||||
display_id = str(request.identifier)[:200]
|
||||
return RestoreDashboardResponse(
|
||||
success=False,
|
||||
error=f"No dashboard found with identifier: {display_id}.",
|
||||
error_type="NotFound",
|
||||
)
|
||||
await ctx.warning(
|
||||
"Permission denied restoring dashboard id=%s" % (dashboard_id,)
|
||||
)
|
||||
return RestoreDashboardResponse(
|
||||
success=False,
|
||||
permission_denied=True,
|
||||
error=(
|
||||
f"You do not have permission to restore dashboard "
|
||||
f"id={dashboard_id}. Ask the user to restore it or grant "
|
||||
"access; do not retry."
|
||||
),
|
||||
error_type="Forbidden",
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
_rollback()
|
||||
logger.exception("Editorship check failed during restore_dashboard")
|
||||
return RestoreDashboardResponse(
|
||||
success=False,
|
||||
error="Dashboard lookup failed due to a database error.",
|
||||
error_type="LookupFailed",
|
||||
)
|
||||
|
||||
# Dashboard titles are user-controlled and must remain exact in response text.
|
||||
dashboard_name = dashboard.dashboard_title
|
||||
|
||||
|
||||
@@ -259,7 +259,9 @@ MCP_FACTORY_CONFIG = {
|
||||
#
|
||||
# Configuration Flow:
|
||||
# -------------------
|
||||
# - MCP_CACHE_CONFIG controls whether caching is enabled and its TTL settings
|
||||
# - MCP_CACHE_CONFIG controls whether caching is enabled and its TTL settings.
|
||||
# Note "enabled" alone is not sufficient -- see
|
||||
# "dangerously_share_cache_across_principals" below.
|
||||
# - MCP_STORE_CONFIG controls the Redis store (optional)
|
||||
#
|
||||
# Scenarios:
|
||||
@@ -270,11 +272,13 @@ MCP_FACTORY_CONFIG = {
|
||||
#
|
||||
# 2. Caching with in-memory store:
|
||||
# MCP_CACHE_CONFIG["enabled"] = True
|
||||
# MCP_CACHE_CONFIG["dangerously_share_cache_across_principals"] = True
|
||||
# MCP_STORE_CONFIG["enabled"] = False (or not configured)
|
||||
# → Caching uses FastMCP's default in-memory store, no Prefix wrapper used
|
||||
#
|
||||
# 3. Caching with Redis store:
|
||||
# MCP_CACHE_CONFIG["enabled"] = True
|
||||
# MCP_CACHE_CONFIG["dangerously_share_cache_across_principals"] = True
|
||||
# MCP_STORE_CONFIG["enabled"] = True
|
||||
# MCP_STORE_CONFIG["CACHE_REDIS_URL"] = "redis://..."
|
||||
# → Caching uses Redis with PrefixKeysWrapper
|
||||
@@ -322,6 +326,13 @@ MCP_STORE_CONFIG: dict[str, Any] = {
|
||||
# When enabled with MCP_STORE_CONFIG, uses Redis store.
|
||||
MCP_CACHE_CONFIG: dict[str, Any] = {
|
||||
"enabled": False, # Disabled by default
|
||||
# Cache keys are method/tool + arguments only and cache hits are served
|
||||
# ahead of per-request auth/RBAC, so a shared cache can return one
|
||||
# caller's response to another. Response caching refuses to start
|
||||
# unless this is explicitly set -- only appropriate when every request
|
||||
# is guaranteed to come from the same principal (e.g. a single-user
|
||||
# development deployment).
|
||||
"dangerously_share_cache_across_principals": False,
|
||||
# Base prefix for the shared store. Superset appends an internal response-
|
||||
# contract namespace so incompatible cached values are not reused.
|
||||
"CACHE_KEY_PREFIX": None, # Only needed when using the store
|
||||
@@ -332,12 +343,38 @@ MCP_CACHE_CONFIG: dict[str, Any] = {
|
||||
"get_prompt_ttl": 60 * 60, # 1 hour
|
||||
"call_tool_ttl": 60 * 60, # 1 hour
|
||||
"max_item_size": 1024 * 1024, # 1MB
|
||||
"excluded_tools": [ # Tools that should never be cached (side effects, dynamic)
|
||||
"execute_sql",
|
||||
"generate_dashboard",
|
||||
# Every tool whose ToolAnnotations set readOnlyHint=False, i.e. every tool
|
||||
# with a side effect. A cache hit is served ahead of per-request
|
||||
# auth/RBAC, so caching a mutating tool can replay a stale create/update/
|
||||
# delete result -- including to a caller who repeats an identical call
|
||||
# expecting it to run again. This list is enforced complete by
|
||||
# test_mcp_caching.py::test_excluded_tools_covers_every_mutating_tool,
|
||||
# which fails with the specific missing tool name(s) if a new
|
||||
# non-read-only tool is added without also being added here.
|
||||
"excluded_tools": [
|
||||
"add_chart_to_existing_dashboard",
|
||||
"create_dataset",
|
||||
"create_theme",
|
||||
"create_virtual_dataset",
|
||||
"delete_chart",
|
||||
"delete_dashboard",
|
||||
"duplicate_dashboard",
|
||||
"execute_sql",
|
||||
"generate_chart",
|
||||
"generate_dashboard",
|
||||
"generate_explore_link",
|
||||
"manage_dashboard_certification",
|
||||
"manage_dashboard_owners",
|
||||
"manage_dashboard_roles",
|
||||
"manage_native_filters",
|
||||
"remove_chart_from_dashboard",
|
||||
"restore_chart",
|
||||
"restore_dashboard",
|
||||
"save_sql_query",
|
||||
"update_chart",
|
||||
"update_chart_preview",
|
||||
"update_dashboard",
|
||||
"update_dataset_metric",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -495,6 +532,16 @@ def create_default_mcp_auth_factory(app: Flask) -> Optional[Any]:
|
||||
if not (auth_enabled or api_key_enabled or guest_enabled):
|
||||
return None
|
||||
|
||||
# MCP_DEV_USERNAME makes user resolution fall back to a fixed user for
|
||||
# requests that carry no resolvable identity, which defeats the point of
|
||||
# having transport auth enabled. Refuse the combination outright.
|
||||
if auth_enabled and app.config.get("MCP_DEV_USERNAME"):
|
||||
raise MCPAuthConfigError(
|
||||
"MCP_DEV_USERNAME must not be set when MCP_AUTH_ENABLED is True: "
|
||||
"it would execute callers without a resolvable identity as that "
|
||||
"user. Unset MCP_DEV_USERNAME (a development-only convenience)."
|
||||
)
|
||||
|
||||
# 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.
|
||||
@@ -519,22 +566,40 @@ def create_default_mcp_auth_factory(app: Flask) -> Optional[Any]:
|
||||
secret = app.config.get("MCP_JWT_SECRET")
|
||||
|
||||
if not (jwks_uri or public_key or secret):
|
||||
logger.warning("MCP_AUTH_ENABLED is True but no JWT keys/secret configured")
|
||||
if not (api_key_enabled or guest_enabled):
|
||||
return None
|
||||
else:
|
||||
try:
|
||||
jwt_verifier = _build_jwt_verifier(
|
||||
app=app,
|
||||
jwks_uri=jwks_uri,
|
||||
public_key=public_key,
|
||||
secret=secret,
|
||||
)
|
||||
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 or guest_enabled):
|
||||
return None
|
||||
# Fail closed regardless of API-key/guest fallbacks: JWT auth was
|
||||
# explicitly enabled, so silently starting without it would leave
|
||||
# the operator's chosen JWT mode disabled without warning them
|
||||
# via anything louder than a log line.
|
||||
raise MCPAuthConfigError(
|
||||
"MCP_AUTH_ENABLED is True but no JWT verification key is "
|
||||
"configured; refusing to start an unauthenticated MCP "
|
||||
"server. Set MCP_JWKS_URI, MCP_JWT_PUBLIC_KEY, or "
|
||||
"MCP_JWT_SECRET (with MCP_JWT_ALGORITHM='HS256')."
|
||||
)
|
||||
|
||||
try:
|
||||
jwt_verifier = _build_jwt_verifier(
|
||||
app=app,
|
||||
jwks_uri=jwks_uri,
|
||||
public_key=public_key,
|
||||
secret=secret,
|
||||
)
|
||||
except MCPAuthConfigError:
|
||||
raise
|
||||
except Exception:
|
||||
# Do not log or chain the exception — it may contain secrets
|
||||
# (e.g., key material)
|
||||
logger.error("Failed to create MCP JWT verifier")
|
||||
# Fail closed regardless of API-key/guest fallbacks: JWT auth
|
||||
# was explicitly enabled, so silently starting without it is
|
||||
# a permissive state the operator did not choose.
|
||||
raise MCPAuthConfigError(
|
||||
"Failed to construct the MCP JWT verifier from the "
|
||||
"configured key material; refusing to start with JWT "
|
||||
"auth silently disabled. Verify MCP_JWT_ALGORITHM "
|
||||
"matches the configured key (HS256 for MCP_JWT_SECRET; "
|
||||
"RS256 needs MCP_JWKS_URI or MCP_JWT_PUBLIC_KEY)."
|
||||
) from None
|
||||
|
||||
# A composite verifier is needed whenever API-key OR guest auth is on, so
|
||||
# those token types are recognized before (or instead of) the JWT verifier.
|
||||
@@ -708,15 +773,49 @@ def _build_jwt_verifier(
|
||||
"required_scopes": app.config.get("MCP_REQUIRED_SCOPES", []),
|
||||
}
|
||||
|
||||
# For HS256 (symmetric), use the secret as the public_key parameter
|
||||
if app.config.get("MCP_JWT_ALGORITHM") == "HS256" and secret:
|
||||
algorithm = app.config.get("MCP_JWT_ALGORITHM", "RS256")
|
||||
|
||||
if algorithm in ("HS256", "HS384", "HS512"):
|
||||
# HMAC algorithms are symmetric: verification MUST be keyed on an
|
||||
# explicit shared secret, never on public-key material (PEM or
|
||||
# JWKS), which isn't confidential. Refuse the contradictory
|
||||
# configuration outright instead of honoring it.
|
||||
if not secret:
|
||||
raise MCPAuthConfigError(
|
||||
f"MCP_JWT_ALGORITHM is '{algorithm}' but MCP_JWT_SECRET is "
|
||||
"not set. Refusing to build an HMAC verifier keyed on "
|
||||
"public-key material. Set MCP_JWT_SECRET, or switch to an "
|
||||
"asymmetric algorithm (e.g. RS256) with MCP_JWT_PUBLIC_KEY "
|
||||
"or MCP_JWKS_URI."
|
||||
)
|
||||
if public_key or jwks_uri:
|
||||
raise MCPAuthConfigError(
|
||||
"MCP_JWT_PUBLIC_KEY/MCP_JWKS_URI are configured alongside "
|
||||
f"MCP_JWT_ALGORITHM='{algorithm}'. This usually indicates "
|
||||
"leftover asymmetric-key configuration; remove the public "
|
||||
"key/JWKS settings, or switch back to an asymmetric "
|
||||
"algorithm."
|
||||
)
|
||||
# For HMAC (symmetric), use the secret as the public_key parameter
|
||||
common_kwargs["public_key"] = secret
|
||||
common_kwargs["algorithm"] = "HS256"
|
||||
common_kwargs["algorithm"] = algorithm
|
||||
else:
|
||||
# For RS256 (asymmetric), use public key or JWKS
|
||||
if not (jwks_uri or public_key):
|
||||
# Only a secret is configured but the algorithm is asymmetric: a
|
||||
# keyless verifier cannot validate anything. Name the fix rather
|
||||
# than letting the verifier constructor raise opaquely (it would
|
||||
# still fail closed via the caller's fail-closed exception
|
||||
# handling, but with a less actionable message).
|
||||
raise MCPAuthConfigError(
|
||||
"MCP_JWT_SECRET is set but MCP_JWT_ALGORITHM is not 'HS256' "
|
||||
"and no MCP_JWKS_URI/MCP_JWT_PUBLIC_KEY is configured. Set "
|
||||
"MCP_JWT_ALGORITHM='HS256' to use the secret, or configure "
|
||||
"an asymmetric key."
|
||||
)
|
||||
common_kwargs["jwks_uri"] = jwks_uri
|
||||
common_kwargs["public_key"] = public_key
|
||||
common_kwargs["algorithm"] = app.config.get("MCP_JWT_ALGORITHM", "RS256")
|
||||
common_kwargs["algorithm"] = algorithm
|
||||
|
||||
if debug_errors:
|
||||
# DetailedJWTVerifier: detailed server-side logging of JWT
|
||||
|
||||
@@ -218,18 +218,30 @@ _SENSITIVE_PARAM_KEYS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_value(value: Any) -> Any:
|
||||
"""Apply ``_sanitize_params`` recursively to any dict/list container."""
|
||||
if isinstance(value, dict):
|
||||
return _sanitize_params(value)
|
||||
if isinstance(value, list):
|
||||
return [_sanitize_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _sanitize_params(params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Remove sensitive fields from params before logging."""
|
||||
"""Remove sensitive fields from params before logging.
|
||||
|
||||
Recurses into nested containers, including lists of lists, so sensitive
|
||||
keys are redacted no matter which wrapper they arrive under
|
||||
(``arguments``, ``request``, etc.).
|
||||
"""
|
||||
if not isinstance(params, dict):
|
||||
return params
|
||||
result: dict[str, Any] = {}
|
||||
for k, v in params.items():
|
||||
if k.lower() in _SENSITIVE_PARAM_KEYS:
|
||||
result[k] = "[REDACTED]"
|
||||
elif k == "arguments" and isinstance(v, dict):
|
||||
result[k] = _sanitize_params(v)
|
||||
else:
|
||||
result[k] = v
|
||||
result[k] = _sanitize_value(v)
|
||||
return result
|
||||
|
||||
|
||||
@@ -1005,7 +1017,9 @@ class GlobalErrorHandlerMiddleware(Middleware):
|
||||
) from error
|
||||
elif isinstance(error, HTTPException):
|
||||
# HTTP errors from screenshot endpoints or API calls
|
||||
raise ToolError(f"Service error in {tool_name}: {error.detail}") from error
|
||||
raise ToolError(
|
||||
f"Service error in {tool_name}: {_sanitize_error_for_logging(error)}"
|
||||
) from error
|
||||
elif isinstance(error, MCPPermissionDeniedError):
|
||||
# MCP RBAC permission denied — convert to structured ToolError.
|
||||
# Must come before the generic PermissionError branch because
|
||||
@@ -1020,7 +1034,8 @@ class GlobalErrorHandlerMiddleware(Middleware):
|
||||
elif isinstance(error, ValueError):
|
||||
# Value/parameter errors from tool code
|
||||
raise ToolError(
|
||||
f"Invalid parameter in {tool_name}: {str(error)}"
|
||||
f"Invalid parameter in {tool_name}: "
|
||||
f"{_sanitize_error_for_logging(error)}"
|
||||
) from error
|
||||
elif isinstance(error, (ObjectNotFoundError, CommandInvalidError)):
|
||||
# Superset command: not found (404) or validation (422)
|
||||
|
||||
@@ -808,11 +808,19 @@ def _create_auth_provider(flask_app: Any) -> Any | None:
|
||||
when either ``MCP_AUTH_ENABLED`` (JWT auth), ``MCP_API_KEY_ENABLED``, or
|
||||
``FAB_API_KEY_ENABLED`` (API key auth) is True. The default factory builds a
|
||||
``CompositeTokenVerifier`` that handles either or both auth modes.
|
||||
|
||||
Fail-closed: when auth has been explicitly configured, any error while
|
||||
building the provider (or a configured factory yielding no provider)
|
||||
raises ``MCPAuthConfigError`` so the service refuses to start rather
|
||||
than coming up as an unauthenticated server.
|
||||
"""
|
||||
from superset.mcp_service.mcp_config import (
|
||||
create_default_mcp_auth_factory,
|
||||
MCPAuthConfigError,
|
||||
)
|
||||
|
||||
auth_provider = None
|
||||
if auth_factory := flask_app.config.get("MCP_AUTH_FACTORY"):
|
||||
from superset.mcp_service.mcp_config import MCPAuthConfigError
|
||||
|
||||
try:
|
||||
auth_provider = auth_factory(flask_app)
|
||||
logger.info(
|
||||
@@ -838,17 +846,18 @@ def _create_auth_provider(flask_app: Any) -> Any | None:
|
||||
"refusing to start the MCP service without authentication. "
|
||||
"Fix the factory or unset MCP_AUTH_FACTORY."
|
||||
) from None
|
||||
if auth_provider is None:
|
||||
raise MCPAuthConfigError(
|
||||
"MCP_AUTH_FACTORY returned no auth provider; refusing to "
|
||||
"start an unauthenticated MCP server. Return a token "
|
||||
"verifier or unset MCP_AUTH_FACTORY."
|
||||
)
|
||||
elif (
|
||||
flask_app.config.get("MCP_AUTH_ENABLED", False)
|
||||
or flask_app.config.get("MCP_API_KEY_ENABLED", False)
|
||||
or flask_app.config.get("FAB_API_KEY_ENABLED", False)
|
||||
or flask_app.config.get("MCP_EMBEDDED_GUEST_AUTH_ENABLED", False)
|
||||
):
|
||||
from superset.mcp_service.mcp_config import (
|
||||
create_default_mcp_auth_factory,
|
||||
MCPAuthConfigError,
|
||||
)
|
||||
|
||||
try:
|
||||
auth_provider = create_default_mcp_auth_factory(flask_app)
|
||||
logger.info(
|
||||
@@ -862,8 +871,19 @@ def _create_auth_provider(flask_app: Any) -> Any | None:
|
||||
# no secret material.
|
||||
raise
|
||||
except Exception:
|
||||
# Do not log the exception — it may contain secrets
|
||||
# Do not log or chain the exception — it may contain secrets.
|
||||
# Auth was explicitly enabled, so a provider that cannot be built
|
||||
# must also fail closed instead of starting unauthenticated.
|
||||
logger.error("Failed to create auth provider from default factory")
|
||||
raise MCPAuthConfigError(
|
||||
"Failed to build the MCP auth provider from the configured "
|
||||
"auth settings; refusing to start an unauthenticated MCP "
|
||||
"server. Check the MCP auth configuration."
|
||||
) from None
|
||||
# ``None`` here is deliberate only when the factory itself resolved
|
||||
# every auth mode to disabled (e.g. MCP_API_KEY_ENABLED=False
|
||||
# explicitly overriding FAB_API_KEY_ENABLED); misconfigurations of an
|
||||
# enabled mode raise MCPAuthConfigError inside the factory instead.
|
||||
return auth_provider
|
||||
|
||||
|
||||
|
||||
@@ -65,7 +65,12 @@ async def _validate_non_destructive_sql(
|
||||
with event_logger.log_context(action="mcp.execute_sql.ddl_check"):
|
||||
try:
|
||||
sql_to_check: str = request.sql
|
||||
if request.template_params:
|
||||
# Render whenever template_params is not None, mirroring the
|
||||
# executor (SQLExecutor._render_sql_template), which also renders
|
||||
# for an empty dict. A truthiness check would let destructive SQL
|
||||
# that only appears after rendering slip past the guard when
|
||||
# template_params={}.
|
||||
if request.template_params is not None:
|
||||
from superset.jinja_context import get_template_processor
|
||||
|
||||
tp = get_template_processor(database=database)
|
||||
|
||||
@@ -24,6 +24,7 @@ system-level info.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, List
|
||||
|
||||
@@ -32,6 +33,12 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
|
||||
from superset.subjects.types import SubjectType
|
||||
|
||||
# Shape-only check, not RFC validation: just enough to catch "local@domain.tld"
|
||||
# so an email-shaped query can be rejected before it reaches the username
|
||||
# column, since usernames are frequently email addresses under OAuth
|
||||
# provisioning.
|
||||
_EMAIL_SHAPE_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
class HealthCheckResponse(BaseModel):
|
||||
"""Response model for health check.
|
||||
@@ -176,7 +183,7 @@ def serialize_user_object(user: Any) -> UserInfo | None:
|
||||
class FindUsersRequest(BaseModel):
|
||||
"""Request schema for find_users tool.
|
||||
|
||||
Resolves a person's name (or partial name, username, or email) to user IDs
|
||||
Resolves a person's name (or partial name or username) to user IDs
|
||||
so they can be passed to listing tools as filter values for created_by_fk
|
||||
or changed_by_fk. This is the only sanctioned path for "show me what
|
||||
<person> is working on" queries.
|
||||
@@ -191,8 +198,9 @@ class FindUsersRequest(BaseModel):
|
||||
max_length=200,
|
||||
description=(
|
||||
"Substring to match (case-insensitive) against username, "
|
||||
"first_name, last_name, and email. Required and non-empty: "
|
||||
"this tool does not enumerate the full user directory."
|
||||
"first_name, and last_name (never email; email-shaped "
|
||||
"queries are rejected). Required and non-empty: this tool "
|
||||
"does not enumerate the full user directory."
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -217,6 +225,20 @@ class FindUsersRequest(BaseModel):
|
||||
raise ValueError("query must contain at least one non-whitespace character")
|
||||
return stripped
|
||||
|
||||
@field_validator("query")
|
||||
@classmethod
|
||||
def _reject_email_shaped_query(cls, value: str) -> str:
|
||||
# Email isn't a searchable column here, but usernames are commonly
|
||||
# email addresses under OAuth provisioning, so an email-shaped query
|
||||
# would still confirm an account's existence via the username column.
|
||||
# Reject the shape outright rather than relying on the column
|
||||
# exclusion alone.
|
||||
if _EMAIL_SHAPE_RE.match(value):
|
||||
raise ValueError(
|
||||
"query must not be an email address; search by name or username instead"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class UserMatch(BaseModel):
|
||||
"""Minimal user projection returned by find_users.
|
||||
|
||||
@@ -50,9 +50,14 @@ async def find_users(request: FindUsersRequest, ctx: Context) -> FindUsersRespon
|
||||
the value for a created_by_fk or changed_by_fk filter on list_dashboards,
|
||||
list_charts, or list_datasets.
|
||||
|
||||
Matches case-insensitively against username, first_name, last_name, and
|
||||
email. The query is required and non-empty; this tool does not enumerate
|
||||
the full user directory.
|
||||
Matches case-insensitively against username, first_name, and last_name.
|
||||
Email is deliberately not matched, and an email-shaped query is rejected
|
||||
outright rather than falling through to the username column: since
|
||||
usernames are frequently email addresses under OAuth provisioning, a
|
||||
plain column exclusion would still let an email lookup confirm whether
|
||||
an address has an account (and resolve it to a person), a directory
|
||||
capability the web API reserves for admins. The query is required and
|
||||
non-empty; this tool does not enumerate the full user directory.
|
||||
|
||||
Privacy: returning a user's identity here is sanctioned only for resolving
|
||||
filter values. Do not use the response to answer "who owns X", "who can
|
||||
@@ -75,7 +80,6 @@ async def find_users(request: FindUsersRequest, ctx: Context) -> FindUsersRespon
|
||||
user_model.username.ilike(needle, escape="\\"),
|
||||
user_model.first_name.ilike(needle, escape="\\"),
|
||||
user_model.last_name.ilike(needle, escape="\\"),
|
||||
user_model.email.ilike(needle, escape="\\"),
|
||||
)
|
||||
)
|
||||
.order_by(user_model.username.asc())
|
||||
|
||||
@@ -179,6 +179,21 @@ SERIES_LIMIT_SUBQ_ALIAS = "series_limit"
|
||||
# Offset join column suffix used for joining offset results
|
||||
OFFSET_JOIN_COLUMN_SUFFIX = "__offset_join_column_"
|
||||
|
||||
|
||||
def get_effective_hours_offset(
|
||||
db_engine_spec: type["BaseEngineSpec"],
|
||||
column_type: str | None,
|
||||
offset_hours: int,
|
||||
db_extra: dict[str, Any] | None = None,
|
||||
) -> int:
|
||||
"""Return the dataset offset representable by a temporal column's type."""
|
||||
sqla_type = db_engine_spec.get_sqla_column_type(column_type, db_extra=db_extra)
|
||||
if isinstance(sqla_type, sa.Date):
|
||||
# int() deliberately truncates toward zero; // would turn -1h into -24h.
|
||||
return int(offset_hours / 24) * 24
|
||||
return offset_hours
|
||||
|
||||
|
||||
# Right suffix used for joining offset results
|
||||
R_SUFFIX = "__right_suffix"
|
||||
|
||||
@@ -1543,6 +1558,7 @@ class QueryResult: # pylint: disable=too-few-public-methods
|
||||
errors: Optional[list[dict[str, Any]]] = None,
|
||||
from_dttm: Optional[datetime] = None,
|
||||
to_dttm: Optional[datetime] = None,
|
||||
sql_shifted_temporal_labels: set[str] | None = None,
|
||||
) -> None:
|
||||
self.df = df
|
||||
self.query = query
|
||||
@@ -1555,6 +1571,7 @@ class QueryResult: # pylint: disable=too-few-public-methods
|
||||
self.errors = errors or []
|
||||
self.from_dttm = from_dttm
|
||||
self.to_dttm = to_dttm
|
||||
self.sql_shifted_temporal_labels = sql_shifted_temporal_labels or set()
|
||||
self.sql_rowcount = len(self.df.index) if not self.df.empty else 0
|
||||
|
||||
|
||||
@@ -1654,6 +1671,7 @@ class QueryStringExtended(NamedTuple):
|
||||
labels_expected: list[str]
|
||||
prequeries: list[str]
|
||||
sql: str
|
||||
sql_shifted_temporal_labels: set[str]
|
||||
|
||||
|
||||
class SqlaQuery(NamedTuple):
|
||||
@@ -1665,6 +1683,7 @@ class SqlaQuery(NamedTuple):
|
||||
labels_expected: list[str]
|
||||
prequeries: list[str]
|
||||
sqla_query: Select
|
||||
sql_shifted_temporal_labels: set[str]
|
||||
|
||||
|
||||
class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
@@ -2019,6 +2038,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
labels_expected=sqlaq.labels_expected,
|
||||
prequeries=sqlaq.prequeries,
|
||||
sql=sql,
|
||||
sql_shifted_temporal_labels=sqlaq.sql_shifted_temporal_labels,
|
||||
)
|
||||
|
||||
def _normalize_prequery_result_type(
|
||||
@@ -2223,6 +2243,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
query=sql,
|
||||
errors=errors,
|
||||
error_message=error_message,
|
||||
sql_shifted_temporal_labels=query_str_ext.sql_shifted_temporal_labels,
|
||||
)
|
||||
|
||||
def exc_query(self, qry: Any) -> QueryResult:
|
||||
@@ -2292,6 +2313,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
df: pd.DataFrame,
|
||||
query_object: QueryObject,
|
||||
already_collected: set[str],
|
||||
sql_shifted_temporal_labels: set[str] | None = None,
|
||||
) -> list[DateColumn]:
|
||||
"""``DateColumn`` entries that only need the dataset HOURS OFFSET (and any
|
||||
time shift) applied, for temporal columns the database already returns as
|
||||
@@ -2311,6 +2333,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
):
|
||||
return []
|
||||
|
||||
sql_shifted_temporal_labels = sql_shifted_temporal_labels or set()
|
||||
extra: list[DateColumn] = []
|
||||
for label in df.columns:
|
||||
if label in already_collected or label == DTTM_ALIAS:
|
||||
@@ -2329,7 +2352,9 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
extra.append(
|
||||
DateColumn(
|
||||
timestamp_format=None,
|
||||
offset=self.offset,
|
||||
offset=(
|
||||
0 if label in sql_shifted_temporal_labels else self.offset
|
||||
),
|
||||
time_shift=query_object.time_shift,
|
||||
col_label=label,
|
||||
)
|
||||
@@ -2337,15 +2362,22 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
already_collected.add(label)
|
||||
return extra
|
||||
|
||||
def normalize_df(self, df: pd.DataFrame, query_object: QueryObject) -> pd.DataFrame:
|
||||
def normalize_df(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
query_object: QueryObject,
|
||||
sql_shifted_temporal_labels: set[str] | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Normalize the dataframe by converting datetime columns and ensuring
|
||||
numerical metrics.
|
||||
|
||||
:param df: The dataframe to normalize
|
||||
:param query_object: The query object with metadata about columns
|
||||
:param sql_shifted_temporal_labels: labels already shifted in generated SQL
|
||||
:return: Normalized dataframe
|
||||
"""
|
||||
sql_shifted_temporal_labels = sql_shifted_temporal_labels or set()
|
||||
labels = self._collect_dttm_labels(query_object)
|
||||
|
||||
# ``get_dataset_timezone`` lives on ``ExploreMixin``; datasource doubles
|
||||
@@ -2357,7 +2389,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
dttm_cols = [
|
||||
DateColumn(
|
||||
timestamp_format=fmt,
|
||||
offset=self.offset,
|
||||
offset=0 if label in sql_shifted_temporal_labels else self.offset,
|
||||
time_shift=query_object.time_shift,
|
||||
timezone=dataset_timezone,
|
||||
col_label=label,
|
||||
@@ -2369,7 +2401,9 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
dttm_cols.append(
|
||||
DateColumn.get_legacy_time_column(
|
||||
timestamp_format=self._python_date_format(query_object.granularity),
|
||||
offset=self.offset,
|
||||
offset=(
|
||||
0 if DTTM_ALIAS in sql_shifted_temporal_labels else self.offset
|
||||
),
|
||||
time_shift=query_object.time_shift,
|
||||
timezone=dataset_timezone,
|
||||
)
|
||||
@@ -2377,7 +2411,10 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
|
||||
dttm_cols.extend(
|
||||
self._offset_only_dttm_cols(
|
||||
df, query_object, {col.col_label for col in dttm_cols}
|
||||
df,
|
||||
query_object,
|
||||
{col.col_label for col in dttm_cols},
|
||||
sql_shifted_temporal_labels,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2423,7 +2460,11 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
df = result.df
|
||||
if not df.empty:
|
||||
# Normalize datetime columns and metrics
|
||||
df = self.normalize_df(df, query_object)
|
||||
df = self.normalize_df(
|
||||
df,
|
||||
query_object,
|
||||
result.sql_shifted_temporal_labels,
|
||||
)
|
||||
|
||||
# Process time offsets if requested
|
||||
if query_object.time_offsets:
|
||||
@@ -2733,7 +2774,9 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
else:
|
||||
# 1. normalize df, set dttm column
|
||||
offset_metrics_df = self.normalize_df(
|
||||
offset_metrics_df, query_object_clone
|
||||
offset_metrics_df,
|
||||
query_object_clone,
|
||||
result.sql_shifted_temporal_labels,
|
||||
)
|
||||
|
||||
# 2. rename extra query columns
|
||||
@@ -3745,6 +3788,8 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
col: AdhocColumn,
|
||||
force_type_check: bool = False,
|
||||
template_processor: Optional[BaseTemplateProcessor] = None,
|
||||
apply_dataset_offset: bool = False,
|
||||
sql_shifted_temporal_labels: set[str] | None = None,
|
||||
) -> tuple[ColumnElement, Optional[GenericDataType]]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -3929,6 +3974,12 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
dataset_timezone = None
|
||||
|
||||
if not dataset_timezone and (offset_hours := getattr(self, "offset", 0) or 0):
|
||||
offset_hours = get_effective_hours_offset(
|
||||
self.db_engine_spec,
|
||||
time_col.type,
|
||||
offset_hours,
|
||||
db_extra=self.db_extra,
|
||||
)
|
||||
if start_dttm is not None:
|
||||
start_dttm = start_dttm - timedelta(hours=offset_hours)
|
||||
if end_dttm is not None:
|
||||
@@ -4298,6 +4349,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
template_kwargs["applied_filters"] = applied_template_filters
|
||||
template_processor = self.get_template_processor(**template_kwargs)
|
||||
prequeries: list[str] = []
|
||||
sql_shifted_temporal_labels: set[str] = set()
|
||||
orderby = orderby or []
|
||||
need_groupby = bool(metrics is not None or groupby)
|
||||
metrics = metrics or []
|
||||
@@ -4406,6 +4458,8 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
col, _unused = self.adhoc_column_to_sqla(
|
||||
col=adhoc_columns_by_label[col],
|
||||
template_processor=template_processor,
|
||||
apply_dataset_offset=True,
|
||||
sql_shifted_temporal_labels=sql_shifted_temporal_labels,
|
||||
)
|
||||
elif col in metrics_by_name:
|
||||
col = metrics_by_name[col].get_sqla_col(
|
||||
@@ -4445,6 +4499,8 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
time_grain=time_grain,
|
||||
label=selected,
|
||||
template_processor=template_processor,
|
||||
apply_dataset_offset=True,
|
||||
sql_shifted_temporal_labels=sql_shifted_temporal_labels,
|
||||
)
|
||||
# if groupby field equals a selected column
|
||||
elif selected in columns_by_name:
|
||||
@@ -4466,6 +4522,8 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
outer, _unused = self.adhoc_column_to_sqla(
|
||||
col=selected,
|
||||
template_processor=template_processor,
|
||||
apply_dataset_offset=True,
|
||||
sql_shifted_temporal_labels=sql_shifted_temporal_labels,
|
||||
)
|
||||
groupby_all_columns[outer.name] = outer
|
||||
if (
|
||||
@@ -4506,6 +4564,8 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
outer, _unused = self.adhoc_column_to_sqla(
|
||||
col=selected,
|
||||
template_processor=template_processor,
|
||||
apply_dataset_offset=True,
|
||||
sql_shifted_temporal_labels=sql_shifted_temporal_labels,
|
||||
)
|
||||
select_exprs.append(outer)
|
||||
continue
|
||||
@@ -4541,7 +4601,10 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
|
||||
if is_timeseries:
|
||||
timestamp = dttm_col.get_timestamp_expression(
|
||||
time_grain=time_grain, template_processor=template_processor
|
||||
time_grain=time_grain,
|
||||
template_processor=template_processor,
|
||||
apply_dataset_offset=True,
|
||||
sql_shifted_temporal_labels=sql_shifted_temporal_labels,
|
||||
)
|
||||
# always put timestamp as the first column
|
||||
select_exprs.insert(0, timestamp)
|
||||
@@ -5350,4 +5413,5 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
labels_expected=labels_expected,
|
||||
sqla_query=qry,
|
||||
prequeries=prequeries,
|
||||
sql_shifted_temporal_labels=sql_shifted_temporal_labels,
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user