Compare commits

..
Author SHA1 Message Date
Elizabeth ThompsonandClaude e878fe7d40 fix(databricks): support cancelling SQL Lab queries on SEA connections
Databricks queries run through the Python Connector previously had no
"stop query" support. The connector's only cancellation mechanism is
Cursor.cancel(), which needs a full command identifier from the same
backend session; the default Thrift backend's identifier includes a
secret that's never exposed via any public/documented accessor, so it
can't be reconstructed on the fresh cursor Superset uses to issue
cancellation. The newer, opt-in SEA (Statement Execution API) backend
uses a plain statement id instead, which can be captured and reused
safely, so cancellation is implemented for that case only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 21:58:10 +00:00
66 changed files with 960 additions and 2008 deletions
-7
View File
@@ -47,13 +47,6 @@ updates:
# as a single manual upgrade anyway. TODO: remove when Babel 8 support is viable.
- dependency-name: "@babel/*"
update-types: ["version-update:semver-major"]
# v2.0.0 renamed ZSTDDecompress to decompress and made it async, breaking
# the webpack dev proxy (see #38662, #39138, #39139). Dependabot reopened
# the same bump in #39369 after the first revert, so pin it here instead
# of relying on a package.json comment (package.json is JSON and can't
# hold comments). Remove this once the proxy code is updated to await
# the async decompress() API.
- dependency-name: "simple-zstd"
directory: "/superset-frontend/"
schedule:
interval: "daily"
+2 -2
View File
@@ -64,7 +64,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -75,6 +75,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
category: "/language:${{matrix.language}}"
+4 -11
View File
@@ -2,29 +2,22 @@
Apache Superset is a data visualization platform with Flask/Python backend and React/TypeScript frontend.
## Run Pre-commit Before Pushing
## ⚠️ CRITICAL: Always Run Pre-commit Before Pushing
Always run pre-commit against the files changed by the current branch before
pushing. This matches CI and keeps unrelated failures already present on
`master` from blocking otherwise independent work.
**ALWAYS run `pre-commit run --all-files` before pushing commits.** CI will fail if pre-commit checks don't pass. This is non-negotiable.
```bash
# Stage your changes first
git add .
# Run pre-commit on staged files
pre-commit run
# Run pre-commit on all files
pre-commit run --all-files
# If there are auto-fixes, stage them and commit
git add .
git commit --amend # or new commit
```
Use `pre-commit run --all-files` when auditing or repairing the repository-wide
baseline. If that check finds failures in files untouched by the current branch,
fix them in a separate branch rather than adding unrelated changes to the
current pull request.
Common pre-commit failures:
- **Formatting** - black, oxfmt, eslint will auto-fix
- **Type errors** - mypy failures need manual fixes
+1 -29
View File
@@ -24,34 +24,6 @@ assists people when migrating to a new version.
## Next
### Soft delete is on by default, and purging is live
`SOFT_DELETE` now ships **on** (`DEFAULT_FEATURE_FLAGS`), so deleting a
dashboard, chart, or dataset archives it rather than removing it. Archived
objects are hidden from normal listings, recoverable from **Recently Archived**,
and permanently removed once the retention window elapses.
`SOFT_DELETE_PURGE_DRY_RUN` also flips to `False`, so the nightly
`deletion_retention.purge_soft_deleted` task deletes for real instead of only
logging `would_purge` counts.
**What operators should do before upgrading:**
- **Size the first live purge.** The first real run removes every entity that
aged past `SOFT_DELETE_RETENTION_DAYS` (default 30) since soft delete began
capturing, which on a busy deployment can be a large batch in one window. To
see the size first, set `SOFT_DELETE_PURGE_DRY_RUN = True`, read the
`would_purge` counts from one nightly run, then set it back.
- **Check a replaced `CELERY_CONFIG`.** A deployment that redefines it rather
than inheriting must carry both `superset.tasks.deletion_retention` in
`imports` and the `deletion_retention.purge_soft_deleted` beat entry;
a startup warning now names whichever is absent.
**Both switches are retained.** `SOFT_DELETE = False` restores hard-delete
behaviour and `SOFT_DELETE_PURGE_DRY_RUN = True` suspends purging, at any time.
One caveat on turning soft delete back off: objects archived while it was on are
**resurrected** into normal listings, since the rows were never removed — an
emergency stop rather than a clean rollback.
### Scheduled report execution now enforces one application deadline
Scheduled report (not alert) executions are now governed by a single
@@ -482,7 +454,7 @@ The task ships in the default `CeleryConfig` (both the `superset.tasks.version_h
Soft-deleted dashboards, charts, and datasets are now permanently removed after a retention window (default 30 days; `SOFT_DELETE_RETENTION_DAYS`, `0` disables; settable per workspace at runtime via the `deletion-retention set-window` CLI, which takes precedence). The `deletion_retention.purge_soft_deleted` Celery beat task runs daily and removes each aged-out entity together with its M:N join rows, owned children, datasource permission, and version-history shadow rows. After purge an entity is **unrecoverable** — its detail and `/restore` endpoints return 404 and its version history is gone.
Purging is **live by default** (`SOFT_DELETE_PURGE_DRY_RUN=False`), so the retention promise above is real on a stock deployment. Set it to `True` to have the task log `would_purge` counts and delete nothing — the lever is retained, so an operator can return to dry-run at any time. Note `would_purge` is an **upper bound** — it counts every entity past the retention window without evaluating deletion blockers, so a real run may purge fewer (entities referenced by report schedules or set as a user's welcome dashboard are blocked and reported separately). The task only acts while the `SOFT_DELETE` rollout flag is on; it now ships on by default.
The introducing release **defaults to dry-run** (`SOFT_DELETE_PURGE_DRY_RUN=True`): the task logs `would_purge` counts but deletes nothing, so operators can validate against production before activating real purging by setting it to `False`. Note `would_purge` is an **upper bound** — it counts every entity past the retention window without evaluating deletion blockers, so a real run may purge fewer (entities referenced by report schedules or set as a user's welcome dashboard are blocked and reported separately). The task only acts while the temporary `SOFT_DELETE` rollout flag is on.
Deployments that replace the default `CELERY_CONFIG` must ensure workers register `superset.tasks.deletion_retention` and schedule the `deletion_retention.purge_soft_deleted` task themselves. The shipped Docker development config uses `imports` and includes both entries. While `SOFT_DELETE` is statically enabled, a missing beat entry logs a startup warning; when the override explicitly defines `imports`, a missing purge module is also reported.
+2 -2
View File
@@ -60,9 +60,9 @@
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@storybook/addon-docs": "^10.5.5",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.47",
"@swc/core": "^1.15.46",
"antd": "^6.5.2",
"baseline-browser-mapping": "^2.11.7",
"baseline-browser-mapping": "^2.11.6",
"caniuse-lite": "^1.0.30001806",
"docusaurus-plugin-openapi-docs": "^5.1.2",
"docusaurus-theme-openapi-docs": "^5.1.2",
+2 -2
View File
@@ -89,9 +89,9 @@
},
{
"name": "SOFT_DELETE",
"default": true,
"default": false,
"lifecycle": "development",
"description": "Temporary rollout / kill-switch gate for soft delete (off = legacy hard delete). An emergency stop, not a clean rollback: flipping ON->OFF resurrects already-soft-deleted rows. Retained through this release as the move-back lever; removed (along with its two gate points \u2014 BaseDAO.delete routing and the do_orm_execute visibility listener) once post-flip confidence is established."
"description": "Temporary rollout / kill-switch gate for soft delete (default off = legacy hard delete). An emergency stop, not a clean rollback: flipping ON->OFF resurrects already-soft-deleted rows. Removed (along with its two gate points \u2014 BaseDAO.delete routing and the do_orm_execute visibility listener) once soft delete is stable."
},
{
"name": "TABLE_V2_TIME_COMPARISON_ENABLED",
@@ -423,7 +423,7 @@ echo "sqlalchemy-cratedb" >> ./docker/requirements-local.txt
#### Databend
The recommended connector library for Databend is [databend-sqlalchemy](https://pypi.org/project/databend-sqlalchemy/).
Superset has been tested on `databend-sqlalchemy>=0.5.5`.
Superset has been tested on `databend-sqlalchemy>=0.2.3`.
The recommended connection string is:
@@ -434,7 +434,7 @@ databend://{username}:{password}@{host}:{port}/{database_name}
Here's a connection string example of Superset connecting to a Databend database:
```
databend://user:password@localhost:8000/default?sslmode=disable
databend://user:password@localhost:8000/default?secure=false
```
#### Databricks
+68 -68
View File
@@ -4787,86 +4787,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.15.46":
version "1.15.46"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.46.tgz#393903c7eda790dbd89abd8fa0afdd9041543e5f"
integrity sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==
"@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.15.46":
version "1.15.46"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.46.tgz#ddf16787e320636621180df480a3490fd9a868ca"
integrity sha512-4Tj4ppVIPCmUMpmGFiGtyEriwLyJ+yi/US4WfBrP/ok8COGddDZXLEzQETnKyK46mjvr1v0jevrS23zjoff7vA==
"@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.15.46":
version "1.15.46"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.46.tgz#7bee01b7311c43b913771ef9c7012931871de73b"
integrity sha512-i8tUGnNjyOgMmfmgFSg4aeJLQoFyfpIHK5FjpQAwpRyQIqEUB2w1e8zIDQzY1WhOxx8NoS1S5iUL813Un4Sf5A==
"@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.15.46":
version "1.15.46"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.46.tgz#964596d757d18f04a02873d85a3660416c09c187"
integrity sha512-c0OnhqzdhfOvv6qhNCcByepB+sNYOGZyhtr2Qa6ZCHvAWTYhSRw4j/u92Stue9PbZ/6q74b9nHzi76+kVzqQHQ==
"@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.15.46":
version "1.15.46"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.46.tgz#213d3ece772689a8166ed51064836346c6ce1c2a"
integrity sha512-imyRpNEcUzFQFV2LE4jL68ErvmKEuZCbvZru77iQREunJ+bR4i658cupTgtG1mLYM3F1Tzy3Sb9xYb02KghWTg==
"@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.15.46":
version "1.15.46"
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.46.tgz#4d2ec554103c6bef60cc1e294f374ea5a5edaf78"
integrity sha512-ctEfcl/HcUeomK33cbySiHZm98GEDIxTm1EkpBsYCiHxElYBzvTXVeuQT2YwbUXn9XCrjiw4ipyUNk33k26qRg==
"@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.15.46":
version "1.15.46"
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.46.tgz#097a19792ec22e2f51f6bfac02da1e0b3f5e5bb1"
integrity sha512-DxlMdnt84TtRVTv7WL/thWyz9+QU8QZNNoAP9rrk0P68LziuhfePp8MjQ44zIprpTHTsEwyziIuGUUN5iSC1bQ==
"@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.15.46":
version "1.15.46"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.46.tgz#39c1ca215f9ca643a4aa3ca6250cc38ba5f5c673"
integrity sha512-SKxI7J6t90XPl8hRUqtJi9NfGdunN/E/vZMc7Bc0figeRdOPDBT+Tm8g7cx9xM0T0mewh2l+8dewa3Am27/P+A==
"@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.15.46":
version "1.15.46"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.46.tgz#323a720bc965fffeedacdc3167b46a291553b5e0"
integrity sha512-qj9T6B7bosI0VEsrWOVXZN1OXxS8Tp63ywyrLxNdOycnUtLdkgYcoBsN5y8ImnDDsnwrEWZOy1e+J4xSe7mA3Q==
"@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.15.46":
version "1.15.46"
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.46.tgz#9c2cfd2a59be74671a018097b8914f8cfbcc698d"
integrity sha512-8p7l4c3LU+eA5g9Et1JPhNeMC1oQwXTGU+uah8DPIBX7YXzqswvaBtyKVmXefVGi/DJU1x3YJsc3mbAp9aWzSQ==
"@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.15.46":
version "1.15.46"
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.46.tgz#bd7bd009a47b0f9826212e7ed36385d32fe193d8"
integrity sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==
"@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.15.46":
version "1.15.46"
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.46.tgz#8371845a5bdb330cf05b009f602bb8c4636c6beb"
integrity sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==
"@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.15.46":
version "1.15.46"
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.46.tgz#8acc0f68ee55010fdc876adf2a8faf0b097c681b"
integrity sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==
dependencies:
"@swc/counter" "^0.1.3"
"@swc/types" "^0.1.27"
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.15.46"
"@swc/core-darwin-x64" "1.15.46"
"@swc/core-linux-arm-gnueabihf" "1.15.46"
"@swc/core-linux-arm64-gnu" "1.15.46"
"@swc/core-linux-arm64-musl" "1.15.46"
"@swc/core-linux-ppc64-gnu" "1.15.46"
"@swc/core-linux-s390x-gnu" "1.15.46"
"@swc/core-linux-x64-gnu" "1.15.46"
"@swc/core-linux-x64-musl" "1.15.46"
"@swc/core-win32-arm64-msvc" "1.15.46"
"@swc/core-win32-ia32-msvc" "1.15.46"
"@swc/core-win32-x64-msvc" "1.15.46"
"@swc/counter@^0.1.3":
version "0.1.3"
@@ -6453,10 +6453,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.7, baseline-browser-mapping@^2.9.19:
version "2.11.7"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.7.tgz#65b41243f29d2cca7b5d72ca6e9b8285df1ed975"
integrity sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.6, baseline-browser-mapping@^2.9.19:
version "2.11.6"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.6.tgz#56934c812026ae4fcdb039fc790a94b9a7d81d63"
integrity sha512-69D/imtToCsIcAl8WBS2YaRwA4jO/j0HhU+hELqMEu9f54MoUtI6+XH5mrKU8rEFNEk/Ui1I2MK4/JkWacclGw==
batch@0.6.1:
version "0.6.1"
+150 -230
View File
@@ -148,7 +148,7 @@
"redux-undo": "^1.0.0-beta9-9-7",
"rison": "^0.1.1",
"scroll-into-view-if-needed": "^3.1.0",
"simple-zstd": "^1.4.2",
"simple-zstd": "^2.1.0",
"stream-browserify": "^3.0.0",
"tinycolor2": "^1.6.0",
"urijs": "^1.19.8",
@@ -187,7 +187,7 @@
"@storybook/react-webpack5": "10.5.5",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.47",
"@swc/core": "^1.15.46",
"@swc/plugin-emotion": "^14.15.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^10.4.1",
@@ -221,7 +221,7 @@
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.7",
"baseline-browser-mapping": "^2.11.6",
"cheerio": "1.2.0",
"concurrently": "^10.0.4",
"copy-webpack-plugin": "^14.0.0",
@@ -11479,9 +11479,9 @@
}
},
"node_modules/@swc/core": {
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.47.tgz",
"integrity": "sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==",
"version": "1.15.46",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.46.tgz",
"integrity": "sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
@@ -11497,18 +11497,18 @@
"url": "https://opencollective.com/swc"
},
"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.15.46",
"@swc/core-darwin-x64": "1.15.46",
"@swc/core-linux-arm-gnueabihf": "1.15.46",
"@swc/core-linux-arm64-gnu": "1.15.46",
"@swc/core-linux-arm64-musl": "1.15.46",
"@swc/core-linux-ppc64-gnu": "1.15.46",
"@swc/core-linux-s390x-gnu": "1.15.46",
"@swc/core-linux-x64-gnu": "1.15.46",
"@swc/core-linux-x64-musl": "1.15.46",
"@swc/core-win32-arm64-msvc": "1.15.46",
"@swc/core-win32-ia32-msvc": "1.15.46",
"@swc/core-win32-x64-msvc": "1.15.46"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
@@ -11520,9 +11520,9 @@
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.47.tgz",
"integrity": "sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==",
"version": "1.15.46",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.46.tgz",
"integrity": "sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==",
"cpu": [
"arm64"
],
@@ -11536,9 +11536,9 @@
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.47.tgz",
"integrity": "sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==",
"version": "1.15.46",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.46.tgz",
"integrity": "sha512-4Tj4ppVIPCmUMpmGFiGtyEriwLyJ+yi/US4WfBrP/ok8COGddDZXLEzQETnKyK46mjvr1v0jevrS23zjoff7vA==",
"cpu": [
"x64"
],
@@ -11552,9 +11552,9 @@
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.47.tgz",
"integrity": "sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==",
"version": "1.15.46",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.46.tgz",
"integrity": "sha512-i8tUGnNjyOgMmfmgFSg4aeJLQoFyfpIHK5FjpQAwpRyQIqEUB2w1e8zIDQzY1WhOxx8NoS1S5iUL813Un4Sf5A==",
"cpu": [
"arm"
],
@@ -11568,9 +11568,9 @@
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.47.tgz",
"integrity": "sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==",
"version": "1.15.46",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.46.tgz",
"integrity": "sha512-c0OnhqzdhfOvv6qhNCcByepB+sNYOGZyhtr2Qa6ZCHvAWTYhSRw4j/u92Stue9PbZ/6q74b9nHzi76+kVzqQHQ==",
"cpu": [
"arm64"
],
@@ -11587,9 +11587,9 @@
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.47.tgz",
"integrity": "sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==",
"version": "1.15.46",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.46.tgz",
"integrity": "sha512-imyRpNEcUzFQFV2LE4jL68ErvmKEuZCbvZru77iQREunJ+bR4i658cupTgtG1mLYM3F1Tzy3Sb9xYb02KghWTg==",
"cpu": [
"arm64"
],
@@ -11606,9 +11606,9 @@
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.47.tgz",
"integrity": "sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==",
"version": "1.15.46",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.46.tgz",
"integrity": "sha512-ctEfcl/HcUeomK33cbySiHZm98GEDIxTm1EkpBsYCiHxElYBzvTXVeuQT2YwbUXn9XCrjiw4ipyUNk33k26qRg==",
"cpu": [
"ppc64"
],
@@ -11625,9 +11625,9 @@
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.47.tgz",
"integrity": "sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==",
"version": "1.15.46",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.46.tgz",
"integrity": "sha512-DxlMdnt84TtRVTv7WL/thWyz9+QU8QZNNoAP9rrk0P68LziuhfePp8MjQ44zIprpTHTsEwyziIuGUUN5iSC1bQ==",
"cpu": [
"s390x"
],
@@ -11644,9 +11644,9 @@
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.47.tgz",
"integrity": "sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==",
"version": "1.15.46",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.46.tgz",
"integrity": "sha512-SKxI7J6t90XPl8hRUqtJi9NfGdunN/E/vZMc7Bc0figeRdOPDBT+Tm8g7cx9xM0T0mewh2l+8dewa3Am27/P+A==",
"cpu": [
"x64"
],
@@ -11663,9 +11663,9 @@
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.47.tgz",
"integrity": "sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==",
"version": "1.15.46",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.46.tgz",
"integrity": "sha512-qj9T6B7bosI0VEsrWOVXZN1OXxS8Tp63ywyrLxNdOycnUtLdkgYcoBsN5y8ImnDDsnwrEWZOy1e+J4xSe7mA3Q==",
"cpu": [
"x64"
],
@@ -11682,9 +11682,9 @@
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.47.tgz",
"integrity": "sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==",
"version": "1.15.46",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.46.tgz",
"integrity": "sha512-8p7l4c3LU+eA5g9Et1JPhNeMC1oQwXTGU+uah8DPIBX7YXzqswvaBtyKVmXefVGi/DJU1x3YJsc3mbAp9aWzSQ==",
"cpu": [
"arm64"
],
@@ -11698,9 +11698,9 @@
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.47.tgz",
"integrity": "sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==",
"version": "1.15.46",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.46.tgz",
"integrity": "sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==",
"cpu": [
"ia32"
],
@@ -11714,9 +11714,9 @@
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.47.tgz",
"integrity": "sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==",
"version": "1.15.46",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.46.tgz",
"integrity": "sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==",
"cpu": [
"x64"
],
@@ -13728,72 +13728,6 @@
"win32"
]
},
"node_modules/@vis.gl/react-mapbox": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/@vis.gl/react-mapbox/-/react-mapbox-8.1.2.tgz",
"integrity": "sha512-g/TbFYympMg+TMS+ctASbcgIxEJRnYL7+VyCo2rI6dpG7dUP4wNOOAUxO8og6OH6QwavaQVYSHcY4mfQL7Ixbw==",
"license": "MIT",
"peerDependencies": {
"mapbox-gl": ">=3.5.0",
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
},
"peerDependenciesMeta": {
"mapbox-gl": {
"optional": true
}
}
},
"node_modules/@vis.gl/react-maplibre": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/@vis.gl/react-maplibre/-/react-maplibre-8.1.2.tgz",
"integrity": "sha512-cjghAMHSJLO3sJ7N6iHJRG+n0l30LtiZ0TNND+HYhHgZDd8UMdNHwcnqQ7Ku0guY3lZ4Rw4LvMqpJYKcqbX39A==",
"license": "MIT",
"dependencies": {
"@maplibre/maplibre-gl-style-spec": "^19.2.1"
},
"peerDependencies": {
"maplibre-gl": ">=4.0.0",
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
},
"peerDependenciesMeta": {
"maplibre-gl": {
"optional": true
}
}
},
"node_modules/@vis.gl/react-maplibre/node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "19.3.3",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.3.tgz",
"integrity": "sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^0.0.1",
"json-stringify-pretty-compact": "^3.0.0",
"minimist": "^1.2.8",
"rw": "^1.3.3",
"sort-object": "^3.0.3"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@vis.gl/react-maplibre/node_modules/json-stringify-pretty-compact": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz",
"integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==",
"license": "MIT"
},
"node_modules/@vis.gl/react-maplibre/node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/@visx/annotation": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@visx/annotation/-/annotation-4.0.0.tgz",
@@ -15678,9 +15612,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.7.tgz",
"integrity": "sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==",
"version": "2.11.6",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.6.tgz",
"integrity": "sha512-69D/imtToCsIcAl8WBS2YaRwA4jO/j0HhU+hELqMEu9f54MoUtI6+XH5mrKU8rEFNEk/Ui1I2MK4/JkWacclGw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -19318,12 +19252,6 @@
"node": ">= 0.4"
}
},
"node_modules/duplex-maker": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/duplex-maker/-/duplex-maker-1.0.0.tgz",
"integrity": "sha512-KoHuzggxg7f+vvjqOHfXxaQYI1POzBm+ah0eec7YDssZmbt6QFBI8d1nl5GQwAgR2f+VQCPvyvZtmWWqWuFtlA==",
"license": "MIT"
},
"node_modules/duplexer2": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
@@ -19369,54 +19297,6 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/duplexify": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz",
"integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.0.0",
"inherits": "^2.0.1",
"readable-stream": "^2.0.0",
"stream-shift": "^1.0.0"
}
},
"node_modules/duplexify/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/duplexify/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/duplexify/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/duplexify/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/earcut": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
@@ -19568,6 +19448,7 @@
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
@@ -32451,6 +32332,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
@@ -33481,17 +33363,6 @@
"pbf": "bin/pbf"
}
},
"node_modules/peek-stream": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/peek-stream/-/peek-stream-1.1.3.tgz",
"integrity": "sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==",
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"duplexify": "^3.5.0",
"through2": "^2.0.3"
}
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@@ -34044,19 +33915,6 @@
"node": ">=8"
}
},
"node_modules/process-streams": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/process-streams/-/process-streams-1.0.3.tgz",
"integrity": "sha512-xkIaM5vYnyekB88WyET78YEqXiaJRy0xcvIdE22n+myhvBT7LlLmX6iAtq7jDvVH8CUx2rqQsd32JdRyJMV3NA==",
"funding": [
"https://www.paypal.com/donate/?hosted_button_id=GB656ZSAEQEXN",
"https://de.liberapay.com/nils.knappmeier/"
],
"license": "MIT",
"dependencies": {
"duplex-maker": "^1.0.0"
}
},
"node_modules/proggy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/proggy/-/proggy-3.0.0.tgz",
@@ -35883,13 +35741,13 @@
}
},
"node_modules/react-map-gl": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-8.1.2.tgz",
"integrity": "sha512-7jBqPoyO30M9GR+qd8utPipWGmpuCPOIJA/ePjR6LjQEZoedTf1hSN1SDj2jj6or8NQxe+ztEi91Bwbs5C+f4A==",
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-8.1.1.tgz",
"integrity": "sha512-aSqFAFoxvY7wxbGI93Dz0E41171mkAb3GcNbnkFIotmu88OFw495os6mIDZSi7irYNT/PZEIOEHUxhun4ToGuQ==",
"license": "MIT",
"dependencies": {
"@vis.gl/react-mapbox": "8.1.2",
"@vis.gl/react-maplibre": "8.1.2"
"@vis.gl/react-mapbox": "8.1.1",
"@vis.gl/react-maplibre": "8.1.1"
},
"peerDependencies": {
"mapbox-gl": ">=1.13.0",
@@ -35906,6 +35764,72 @@
}
}
},
"node_modules/react-map-gl/node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "19.3.3",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.3.tgz",
"integrity": "sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^0.0.1",
"json-stringify-pretty-compact": "^3.0.0",
"minimist": "^1.2.8",
"rw": "^1.3.3",
"sort-object": "^3.0.3"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/react-map-gl/node_modules/@vis.gl/react-mapbox": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/@vis.gl/react-mapbox/-/react-mapbox-8.1.1.tgz",
"integrity": "sha512-KMDTjtWESXxHS4uqWxjsvgQUHvuL3Z6SdKe68o7Nxma2qUfuyH3x4TCkIqGn3FQTrFvZLWvTnSAbGvtm+Kd13A==",
"license": "MIT",
"peerDependencies": {
"mapbox-gl": ">=3.5.0",
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
},
"peerDependenciesMeta": {
"mapbox-gl": {
"optional": true
}
}
},
"node_modules/react-map-gl/node_modules/@vis.gl/react-maplibre": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/@vis.gl/react-maplibre/-/react-maplibre-8.1.1.tgz",
"integrity": "sha512-iUOfzJAhFAJwEZp1644tQb7LOTFgi5/GzdaztkhzNgFVuoF2Ez7guvwZjQAKB9CN2TlHTgNuYH8UW85kO7cVhw==",
"license": "MIT",
"dependencies": {
"@maplibre/maplibre-gl-style-spec": "^19.2.1"
},
"peerDependencies": {
"maplibre-gl": ">=4.0.0",
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
},
"peerDependenciesMeta": {
"maplibre-gl": {
"optional": true
}
}
},
"node_modules/react-map-gl/node_modules/json-stringify-pretty-compact": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz",
"integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==",
"license": "MIT"
},
"node_modules/react-map-gl/node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -38041,24 +37965,17 @@
"license": "MIT"
},
"node_modules/simple-zstd": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/simple-zstd/-/simple-zstd-1.4.2.tgz",
"integrity": "sha512-kGYEvT33M5XfyQvvW4wxl3eKcWbdbCc1V7OZzuElnaXft0qbVzoIIXHXiCm3JCUki+MZKKmvjl8p2VGLJc5Y/A==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/simple-zstd/-/simple-zstd-2.1.0.tgz",
"integrity": "sha512-pYzmKWl167db0EHoczlsSpmyjvZ7OinXciHicDEtlHjSKZlo1hPz6tXSyOfS84QIrbWPYT0XW9tx24YtGdQ6cA==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4",
"is-zst": "^1.0.0",
"peek-stream": "^1.1.3",
"process-streams": "^1.0.1",
"through2": "^4.0.2"
}
},
"node_modules/simple-zstd/node_modules/through2": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
"integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==",
"license": "MIT",
"dependencies": {
"readable-stream": "3"
"tmp-promise": "^3.0.3"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/sirv": {
@@ -39022,12 +38939,6 @@
"readable-stream": "^3.5.0"
}
},
"node_modules/stream-shift": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
"license": "MIT"
},
"node_modules/streamx": {
"version": "2.21.1",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.21.1.tgz",
@@ -40452,12 +40363,20 @@
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.14"
}
},
"node_modules/tmp-promise": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz",
"integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==",
"license": "MIT",
"dependencies": {
"tmp": "^0.2.0"
}
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -43114,6 +43033,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/write-file-atomic": {
@@ -44774,7 +44694,7 @@
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.27.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.2",
"react-map-gl": "^8.1.1",
"supercluster": "^8.0.1"
},
"peerDependencies": {
@@ -44881,7 +44801,7 @@
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.4",
"prop-types": "^15.8.1",
"react-map-gl": "^8.1.2",
"react-map-gl": "^8.1.1",
"tinycolor2": "^1.6.0",
"urijs": "^1.19.11",
"xss": "^1.0.15"
+3 -3
View File
@@ -225,7 +225,7 @@
"redux-undo": "^1.0.0-beta9-9-7",
"rison": "^0.1.1",
"scroll-into-view-if-needed": "^3.1.0",
"simple-zstd": "^1.4.2",
"simple-zstd": "^2.1.0",
"stream-browserify": "^3.0.0",
"tinycolor2": "^1.6.0",
"urijs": "^1.19.8",
@@ -264,7 +264,7 @@
"@storybook/react-webpack5": "10.5.5",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.47",
"@swc/core": "^1.15.46",
"@swc/plugin-emotion": "^14.15.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^10.4.1",
@@ -298,7 +298,7 @@
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.7",
"baseline-browser-mapping": "^2.11.6",
"cheerio": "1.2.0",
"concurrently": "^10.0.4",
"copy-webpack-plugin": "^14.0.0",
@@ -34,9 +34,8 @@ export const DropdownButton = ({
const { type: buttonType } = rest;
// divider implementation for default (non-primary) buttons
const defaultBtnCss = css`
${
(!buttonType || buttonType === 'default') &&
`.ant-dropdown-trigger {
${(!buttonType || buttonType === 'default') &&
`.ant-dropdown-trigger {
position: relative;
&:before {
content: '';
@@ -49,8 +48,7 @@ export const DropdownButton = ({
.anticon {
vertical-align: middle;
}
}`
}
}`}
`;
const button = (
<Dropdown.Button
@@ -60,13 +58,13 @@ export const DropdownButton = ({
defaultBtnCss,
css`
.ant-btn {
height: ${
styleConfig?.controlHeight ?? theme.buttonControlHeightSM ?? 30
}px;
height: ${styleConfig?.controlHeight ??
theme.buttonControlHeightSM ??
30}px;
box-shadow: ${styleConfig?.boxShadow ?? 'none'};
font-size: ${
styleConfig?.fontSize ?? theme.buttonFontSize ?? theme.fontSizeSM
}px;
font-size: ${styleConfig?.fontSize ??
theme.buttonFontSize ??
theme.fontSizeSM}px;
font-weight: ${styleConfig?.fontWeight ?? theme.fontWeightStrong};
}
`,
@@ -221,22 +221,18 @@ export const DynamicEditableTitle = memo(
onPressEnter={handleKeyPress}
placeholder={placeholder}
css={css`
${
!canEdit &&
`&[disabled] {
${!canEdit &&
`&[disabled] {
cursor: default;
}
`
}
`}
font-size: ${theme.fontSizeXL}px;
transition: auto;
${
inputWidth &&
inputWidth > 0 &&
css`
width: ${inputWidth}px;
`
}
${inputWidth &&
inputWidth > 0 &&
css`
width: ${inputWidth}px;
`}
`}
disabled={!canEdit}
/>
@@ -171,11 +171,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
{image && <ImageContainer image={image} size={size} />}
<div
css={(theme: SupersetTheme) => css`
max-width: ${
containerSize === 'large'
? theme.sizeUnit * 150
: theme.sizeUnit * 100
}px;
max-width: ${containerSize === 'large'
? theme.sizeUnit * 150
: theme.sizeUnit * 100}px;
`}
>
{title && <Title size={effectiveTextSize}>{title}</Title>}
@@ -73,16 +73,14 @@ export const StyledModal = styled(BaseModal)<StyledModalProps>`
const closeButtonWidth = theme.sizeUnit * 14;
return css`
${
responsive &&
css`
max-width: ${maxWidth ?? '900px'};
padding-left: ${theme.sizeUnit * 3}px;
padding-right: ${theme.sizeUnit * 3}px;
padding-bottom: 0;
top: 0;
`
}
${responsive &&
css`
max-width: ${maxWidth ?? '900px'};
padding-left: ${theme.sizeUnit * 3}px;
padding-right: ${theme.sizeUnit * 3}px;
padding-bottom: 0;
top: 0;
`}
.ant-modal-container {
background-color: ${theme.colorBgContainer};
@@ -170,46 +168,40 @@ export const StyledModal = styled(BaseModal)<StyledModalProps>`
padding: 0;
}
${
draggable &&
css`
.ant-modal-header {
padding: 0;
${draggable &&
css`
.ant-modal-header {
padding: 0;
.draggable-trigger {
cursor: move;
padding: ${theme.sizeUnit * 4}px ${closeButtonWidth}px
${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px;
width: 100%;
.draggable-trigger {
cursor: move;
padding: ${theme.sizeUnit * 4}px ${closeButtonWidth}px
${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px;
width: 100%;
}
}
`}
${resizable &&
css`
.resizable {
pointer-events: all;
.resizable-wrapper {
height: 100%;
}
.ant-modal-container {
height: 100%;
.ant-modal-body {
height: ${hideFooter
? `calc(100% - ${MODAL_HEADER_HEIGHT}px)`
: `calc(100% - ${MODAL_HEADER_HEIGHT}px - ${MODAL_FOOTER_HEIGHT}px)`};
}
}
`
}
${
resizable &&
css`
.resizable {
pointer-events: all;
.resizable-wrapper {
height: 100%;
}
.ant-modal-container {
height: 100%;
.ant-modal-body {
height: ${
hideFooter
? `calc(100% - ${MODAL_HEADER_HEIGHT}px)`
: `calc(100% - ${MODAL_HEADER_HEIGHT}px - ${MODAL_FOOTER_HEIGHT}px)`
};
}
}
}
`
}
}
`}
`;
}}
`;
@@ -51,10 +51,8 @@ const StyledTabs = ({
.ant-tabs-body-holder {
overflow: ${allowOverflow ? 'visible' : 'auto'};
${fullHeight && 'height: 100%;'}
${
contentHeight &&
`height: ${typeof contentHeight === 'number' ? `${contentHeight}px` : contentHeight};`
}
${contentHeight &&
`height: ${typeof contentHeight === 'number' ? `${contentHeight}px` : contentHeight};`}
${contentPadding}
}
.ant-tabs-body {
@@ -68,11 +66,9 @@ const StyledTabs = ({
margin: 0;
}
.ant-tabs-nav-wrap {
${
!(tabBarStyle && 'paddingLeft' in tabBarStyle)
? `padding: 0 ${theme.sizeUnit * 4}px;`
: ''
}
${!(tabBarStyle && 'paddingLeft' in tabBarStyle)
? `padding: 0 ${theme.sizeUnit * 4}px;`
: ''}
}
.ant-tabs-tab {
flex: 1 1 auto;
@@ -19,16 +19,10 @@
import { expect } from '@playwright/test';
import { Modal, Input } from '../core';
import { isFeatureEnabled } from '../../helpers/featureFlags';
/**
* Delete confirmation modal, used throughout Superset for delete operations.
*
* The modal has two modes. Destructive mode demands the user type "DELETE"
* before the action is enabled. Recoverable mode what `SOFT_DELETE` turns
* on archives instead of removing, so the action reads "Archive" and the
* type-to-confirm friction is deliberately dropped: reduced friction is what
* a reversible action earns.
* Delete confirmation modal that requires typing "DELETE" to confirm.
* Used throughout Superset for destructive delete operations.
*
* Provides primitives for tests to compose deletion flows.
*/
@@ -102,40 +96,4 @@ export class DeleteConfirmationModal extends Modal {
await expect(confirmButton).toBeEnabled({ timeout: options?.timeout });
await confirmButton.click(options);
}
/**
* Confirms the deletion using whichever interaction the modal is in.
*
* Which mode is in force is a property of the instance, not of the caller,
* so the flag decides rather than the test the same spec then covers the
* hard-delete and archive paths without being rewritten when the default
* flips, and keeps covering hard delete for deployments that turn the
* toggle back off.
*
* Neither branch is merely tolerant: the recoverable branch asserts the
* confirmation input is genuinely *absent* rather than skipping past it,
* so a regression that dropped the typed confirmation from destructive
* mode still fails here instead of quietly passing.
*
* Assumes the modal's mode follows `SOFT_DELETE` alone. That holds
* everywhere except a bulk selection containing semantic views, which
* stays destructive even with the flag on such a flow should drive
* {@link fillConfirmationInput} and {@link clickDelete} directly.
*
* @param confirmationText - Text typed in destructive mode
*
* @example
* const deleteModal = new DeleteConfirmationModal(page);
* await deleteModal.waitForVisible();
* await deleteModal.confirmDeletion();
* await deleteModal.waitForHidden();
*/
async confirmDeletion(confirmationText = 'DELETE'): Promise<void> {
if (await isFeatureEnabled(this.page, 'SOFT_DELETE')) {
await expect(this.confirmationInput.element).toHaveCount(0);
} else {
await this.fillConfirmationInput(confirmationText);
}
await this.clickDelete();
}
}
@@ -76,9 +76,11 @@ test('should delete a chart with confirmation', async ({
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Confirm: types "DELETE" while the modal is destructive, and goes straight
// through once SOFT_DELETE makes it a recoverable archive instead.
await deleteModal.confirmDeletion();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Modal should close
await deleteModal.waitForHidden();
@@ -235,9 +237,11 @@ test('should bulk delete multiple charts', async ({
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Confirm: types "DELETE" while the modal is destructive, and goes straight
// through once SOFT_DELETE makes it a recoverable archive instead.
await deleteModal.confirmDeletion();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Modal should close
await deleteModal.waitForHidden();
@@ -81,10 +81,11 @@ test('should delete a dashboard with confirmation', async ({
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Confirm: types "DELETE" while the modal is destructive, and goes straight
// through once SOFT_DELETE makes it a recoverable archive instead. Either
// way it waits for the action to become enabled.
await deleteModal.confirmDeletion();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button (waits for it to become enabled)
await deleteModal.clickDelete();
// Modal should close
await deleteModal.waitForHidden();
@@ -190,9 +191,11 @@ test('should bulk delete multiple dashboards', async ({
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Confirm: types "DELETE" while the modal is destructive, and goes straight
// through once SOFT_DELETE makes it a recoverable archive instead.
await deleteModal.confirmDeletion();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Modal should close
await deleteModal.waitForHidden();
@@ -37,7 +37,6 @@ import {
ENDPOINTS,
} from '../../helpers/api/dataset';
import { createTestDataset } from './dataset-test-helpers';
import { isFeatureEnabled } from '../../helpers/featureFlags';
import {
waitForGet,
waitForPost,
@@ -121,21 +120,19 @@ test('should delete a dataset with confirmation', async ({
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Confirm: types "DELETE" while the modal is destructive, and goes straight
// through once SOFT_DELETE makes it a recoverable archive instead.
await deleteModal.confirmDeletion();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears with correct message. The copy names what
// actually happened, so it tracks the mode: archiving is not deleting, and
// a toast saying otherwise would misreport a recoverable action as final.
// Verify success toast appears with correct message.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
await expect(toast.getMessage()).toContainText(
(await isFeatureEnabled(page, 'SOFT_DELETE')) ? 'Archived' : 'Deleted',
);
await expect(toast.getMessage()).toContainText('Deleted');
// Verify dataset is removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
await expect(datasetListPage.getDatasetRow(datasetName)).toHaveCount(0, {
@@ -434,9 +431,11 @@ test('should bulk delete multiple datasets', async ({
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Confirm: types "DELETE" while the modal is destructive, and goes straight
// through once SOFT_DELETE makes it a recoverable archive instead.
await deleteModal.confirmDeletion();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Modal should close
await deleteModal.waitForHidden();
@@ -34,7 +34,7 @@
* unsafe to run on parallel workers, and left version records behind that no
* revert could remove version history being append-only is the point.
*/
import { Page } from '@playwright/test';
import { APIRequestContext, Page } from '@playwright/test';
import rison from 'rison';
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiGet } from '../../helpers/api/requests';
@@ -55,15 +55,17 @@ const OPAQUE_ID =
* `can_overwrite` gate derived from them) live in the Subject id space, not
* the user id space.
*/
async function currentUserSubjectId(page: Page): Promise<number> {
const meRes = await apiGet(page, 'api/v1/me/');
async function currentUserSubjectId(
request: APIRequestContext,
): Promise<number> {
const meRes = await request.get('/api/v1/me/');
expect(meRes.ok(), 'current user request').toBeTruthy();
const userId = (await meRes.json()).result.id;
const q = encodeURIComponent(
`(filters:!((col:user_id,opr:eq,value:${userId})))`,
);
const res = await apiGet(page, `api/v1/security/subject/?q=${q}`);
const res = await request.get(`/api/v1/security/subject/?q=${q}`);
expect(res.ok(), 'subject lookup request').toBeTruthy();
const subjects = (await res.json()).result;
expect(
@@ -139,7 +141,7 @@ testWithAssets(
// without editors.
// Two renames: the first edit on an as-yet-untracked chart collapses into
// "first tracked save"; the second is a normal descriptive save.
const adminSubjectId = await currentUserSubjectId(page);
const adminSubjectId = await currentUserSubjectId(page.request);
await apiPutChart(page, chartId, {
editors: [adminSubjectId],
slice_name: `${baseName} ·vh1`,
@@ -60,9 +60,11 @@ const DEFAULT_LEGEND_ICON_WIDTH = 25;
const LEGEND_ICON_LABEL_GAP = 5;
const LEGEND_HORIZONTAL_SIDE_GUTTER = 16;
const LEGEND_HORIZONTAL_ROW_HEIGHT = 24;
// Cap the reserved horizontal legend margin so an overflowing legend can't eat the plot.
const MAX_LEGEND_MARGIN_RATIO = 0.4;
const LEGEND_HORIZONTAL_MAX_ROWS = 2;
const LEGEND_HORIZONTAL_MAX_HEIGHT_RATIO = 0.25;
const LEGEND_VERTICAL_SIDE_GUTTER = 16;
const LEGEND_VERTICAL_ROW_HEIGHT = 24;
const LEGEND_VERTICAL_MAX_WIDTH_RATIO = 0.4;
const LEGEND_SELECTOR_GAP = 10;
const LEGEND_MARGIN_GUTTER = 45;
// ECharts does not expose pre-render measurements for plain legends, so these
@@ -82,6 +84,10 @@ export type LegendLayoutResult = {
effectiveType: LegendType;
};
const SCROLL_LEGEND_LAYOUT: LegendLayoutResult = {
effectiveType: LegendType.Scroll,
};
function getLegendLabel(item: LegendDataItem): string {
if (typeof item === 'string' || typeof item === 'number') {
return String(item);
@@ -263,30 +269,37 @@ function getHorizontalPlainLegendLayout({
showSelectors,
theme,
);
const rowsForMargin = Number.isFinite(rowCount)
? rowCount
: legendLabels.length;
const requiredMargin =
defaultLegendPadding[orientation] +
Math.max(0, rowsForMargin - 1) * LEGEND_HORIZONTAL_ROW_HEIGHT;
const boundedMargin =
Math.max(0, rowCount - 1) * LEGEND_HORIZONTAL_ROW_HEIGHT;
const maxLegendHeight =
availableHeight > 0
? Math.min(requiredMargin, availableHeight * MAX_LEGEND_MARGIN_RATIO)
: requiredMargin;
? availableHeight * LEGEND_HORIZONTAL_MAX_HEIGHT_RATIO
: Infinity;
if (
!Number.isFinite(rowCount) ||
rowCount > LEGEND_HORIZONTAL_MAX_ROWS ||
requiredMargin > maxLegendHeight
) {
return SCROLL_LEGEND_LAYOUT;
}
return {
effectiveMargin: Math.max(currentMargin, boundedMargin),
effectiveMargin: Math.max(currentMargin, requiredMargin),
effectiveType: LegendType.Plain,
};
}
function getVerticalPlainLegendLayout({
availableHeight,
availableWidth,
currentMargin,
legendLabels,
showSelectors,
theme,
}: {
availableHeight: number;
availableWidth: number;
currentMargin: number;
legendLabels: string[];
@@ -300,6 +313,17 @@ function getVerticalPlainLegendLayout({
};
}
const selectorHeight = showSelectors
? LEGEND_VERTICAL_ROW_HEIGHT + LEGEND_SELECTOR_GAP
: 0;
const effectiveAvailableHeight = Math.max(
availableHeight - LEGEND_VERTICAL_SIDE_GUTTER - selectorHeight,
0,
);
const rowsPerColumn = Math.floor(
(effectiveAvailableHeight + DEFAULT_LEGEND_ITEM_GAP) /
(LEGEND_VERTICAL_ROW_HEIGHT + DEFAULT_LEGEND_ITEM_GAP),
);
const requiredSelectorMargin = showSelectors
? ESTIMATED_LEGEND_SELECTOR_WIDTH + LEGEND_VERTICAL_SIDE_GUTTER
: 0;
@@ -309,13 +333,21 @@ function getVerticalPlainLegendLayout({
requiredSelectorMargin,
),
);
const boundedMargin =
const maxLegendWidth =
availableWidth > 0
? Math.min(requiredMargin, availableWidth * MAX_LEGEND_MARGIN_RATIO)
: requiredMargin;
? availableWidth * LEGEND_VERTICAL_MAX_WIDTH_RATIO
: Infinity;
if (
rowsPerColumn <= 0 ||
legendLabels.length > rowsPerColumn ||
requiredMargin > maxLegendWidth
) {
return SCROLL_LEGEND_LAYOUT;
}
return {
effectiveMargin: Math.max(currentMargin, boundedMargin),
effectiveMargin: Math.max(currentMargin, requiredMargin),
effectiveType: LegendType.Plain,
};
}
@@ -372,6 +404,7 @@ export function getLegendLayoutResult({
}
return getVerticalPlainLegendLayout({
availableHeight: resolvedAvailableHeight,
availableWidth: resolvedAvailableWidth,
currentMargin: resolvedLegendMargin,
legendLabels,
@@ -18,7 +18,6 @@
*/
import { AxisType, ChartProps } from '@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
import type { LegendComponentOption } from 'echarts/components';
import {
LegendOrientation,
LegendType,
@@ -305,7 +304,7 @@ describe('legend sorting', () => {
expect(legendData).toEqual(['series value 2', 'series value 1']);
});
test('honors an explicit List selection for plain legends with an overlong legend item', () => {
test('falls back to scroll for plain legends with an overlong legend item', () => {
const props = new ChartProps({
...chartPropsConfig,
width: 320,
@@ -322,7 +321,7 @@ describe('legend sorting', () => {
'Y Axis': 'first',
tooltip_column: 'tooltip value 1',
series:
'This is a ridiculously long legend label that stays a plain List',
'This is a ridiculously long legend label that should switch to scroll',
},
{
startTime: Date.UTC(2025, 1, 1, 18, 0, 0),
@@ -345,9 +344,7 @@ describe('legend sorting', () => {
const result = transformProps(props as EchartsGanttChartProps);
expect((result.echartOptions.legend as LegendComponentOption).type).toBe(
LegendType.Plain,
);
expect((result.echartOptions.legend as any).type).toBe(LegendType.Scroll);
});
test('keeps legend visibility driven by showLegend for single-series charts', () => {
@@ -141,7 +141,7 @@ describe('Pie transformProps', () => {
]);
});
test('honors an explicit List selection for plain legends with overlong labels', () => {
test('falls back to scroll for plain legends with overlong labels', () => {
const longLegendChartProps = new ChartProps({
formData: {
colorScheme: 'bnbColors',
@@ -182,7 +182,7 @@ describe('Pie transformProps', () => {
);
expect((transformed.echartOptions.legend as any).type).toBe(
LegendType.Plain,
LegendType.Scroll,
);
});
});
@@ -852,7 +852,7 @@ describe('Bar Chart X-axis Time Formatting', () => {
type: LegendType.Plain,
});
test('honors an explicit List selection for horizontal bottom legends and reserves margin', () => {
test('should fall back to scroll for horizontal bottom legends after margin expansion reduces available width', () => {
const legendLabels = [
'This is a long sales legend',
'This is a long marketing legend',
@@ -899,9 +899,48 @@ describe('Bar Chart X-axis Time Formatting', () => {
legendType: LegendType.Plain,
showLegend: true,
};
// A narrow chart forces the long-label bottom legend to wrap onto
// multiple rows — the case that previously flipped List to scroll.
const chartWidth = 320;
const baselineChartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: regressionFormData,
defaultVizType: 'echarts_timeseries_bar',
defaultQueriesData: longLegendData,
width: baseChartPropsConfig.width,
height: baseChartPropsConfig.height,
});
const baselineTransformed = transformProps(baselineChartProps);
const legendItems = (
(baselineTransformed.echartOptions.legend as LegendComponentOption)
.data as Array<string | { name: string }>
).map(item => (typeof item === 'string' ? item : item.name));
let chartWidth: number | undefined;
let expandedLegendMargin: number | null = null;
for (let width = 300; width <= 700; width += 1) {
const initialLayout = getBottomLegendLayout(width, legendItems, null);
if (initialLayout.effectiveType !== LegendType.Plain) {
continue;
}
const refinedLayout = getBottomLegendLayout(
width,
legendItems,
initialLayout.effectiveMargin ?? null,
);
if (refinedLayout.effectiveType === LegendType.Scroll) {
chartWidth = width;
expandedLegendMargin = initialLayout.effectiveMargin ?? null;
break;
}
}
expect(chartWidth).toBeDefined();
expect(expandedLegendMargin).not.toBeNull();
const resolvedChartWidth = chartWidth ?? baseChartPropsConfig.width;
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
@@ -909,7 +948,7 @@ describe('Bar Chart X-axis Time Formatting', () => {
defaultFormData: regressionFormData,
defaultVizType: 'echarts_timeseries_bar',
defaultQueriesData: longLegendData,
width: chartWidth,
width: resolvedChartWidth,
height: baseChartPropsConfig.height,
});
@@ -917,12 +956,7 @@ describe('Bar Chart X-axis Time Formatting', () => {
const legend = transformedProps.echartOptions
.legend as LegendComponentOption;
const grid = transformedProps.echartOptions.grid as GridComponentOption;
const legendItems = (legend.data as Array<string | { name: string }>).map(
item => (typeof item === 'string' ? item : item.name),
);
const layout = getBottomLegendLayout(chartWidth, legendItems, null);
const basePadding = getPadding(
const expectedPadding = getPadding(
true,
LegendOrientation.Bottom,
false,
@@ -934,41 +968,30 @@ describe('Bar Chart X-axis Time Formatting', () => {
undefined,
true,
);
[basePadding.bottom, basePadding.left] = [
basePadding.left,
basePadding.bottom,
[expectedPadding.bottom, expectedPadding.left] = [
expectedPadding.left,
expectedPadding.bottom,
];
// The explicit List selection is honored end-to-end (never flips).
expect(legend.type).toBe(LegendType.Plain);
expect(layout.effectiveType).toBe(LegendType.Plain);
// #38675's margin reservation is retained: the wrapped rows reserve a
// finite margin beyond the single-row baseline, so the grid shrinks to
// reduce clipping instead of the legend flipping to scroll.
expect(Number.isFinite(layout.effectiveMargin)).toBe(true);
const reservedPadding = getPadding(
const expandedPadding = getPadding(
true,
LegendOrientation.Bottom,
false,
false,
layout.effectiveMargin,
expandedLegendMargin,
false,
undefined,
undefined,
undefined,
true,
);
[reservedPadding.bottom, reservedPadding.left] = [
reservedPadding.left,
reservedPadding.bottom,
[expandedPadding.bottom, expandedPadding.left] = [
expandedPadding.left,
expandedPadding.bottom,
];
expect(grid.bottom).toBe(reservedPadding.bottom);
expect(grid.bottom as number).toBeGreaterThan(
basePadding.bottom as number,
);
expect(legend.type).toBe(LegendType.Scroll);
expect(grid.bottom).toBe(expectedPadding.bottom);
expect(grid.bottom).not.toBe(expandedPadding.bottom);
});
});
});
@@ -916,38 +916,40 @@ describe('legend sorting', () => {
'Boston',
]);
});
});
test('honors an explicit List selection for zoomable top legends even when toolbox space reduces available width', () => {
const narrowLegendData = [
createTestQueryData(
createTestData(
[
{
Alpha: 1,
Beta: 2,
Gamma: 3,
},
],
{ intervalMs: 300000000 },
test('falls back to scroll for zoomable top legends when toolbox space reduces available width', () => {
const narrowLegendData = [
createTestQueryData(
createTestData(
[
{
Alpha: 1,
Beta: 2,
Gamma: 3,
},
],
{ intervalMs: 300000000 },
),
),
),
];
const chartProps = createTestChartProps({
width: 190 + TIMESERIES_CONSTANTS.legendTopRightOffset,
formData: {
...formData,
legendType: LegendType.Plain,
legendOrientation: LegendOrientation.Top,
showLegend: true,
zoomable: true,
},
queriesData: narrowLegendData,
];
const chartProps = createTestChartProps({
width: 190 + TIMESERIES_CONSTANTS.legendTopRightOffset,
formData: {
...formData,
legendType: LegendType.Plain,
legendOrientation: LegendOrientation.Top,
showLegend: true,
zoomable: true,
},
queriesData: narrowLegendData,
});
const transformed = transformProps(chartProps);
expect((transformed.echartOptions.legend as any).type).toBe(
LegendType.Scroll,
);
});
const transformed = transformProps(chartProps);
expect((transformed.echartOptions.legend as any).type).toBe(LegendType.Plain);
});
test('honors user-selected plain legend type for top orientation when space allows (#39540)', () => {
@@ -1018,8 +1018,9 @@ test('getLegendLayoutResult honors user-selected plain type for many horizontal
});
expect(layout.effectiveType).toBe(LegendType.Plain);
// One row of items fits, so only the base top padding is reserved.
expect(layout.effectiveMargin).toBe(20);
expect(layout.effectiveMargin).toBeGreaterThanOrEqual(
defaultLegendPadding[LegendOrientation.Top],
);
});
test('getLegendLayoutResult keeps user-selected plain type for bottom-oriented legends when space allows', () => {
@@ -1081,85 +1082,47 @@ test('getLegendLayoutResult adds extra margin for wrapped plain horizontal legen
);
});
test('getLegendLayoutResult keeps plain when horizontal plain legends exceed two rows', () => {
const layout = getLegendLayoutResult({
chartHeight: 400,
chartWidth: 240,
legendItems: [
'This is a long legend label',
'Another long legend label',
'Third long legend label',
],
legendMargin: null,
orientation: LegendOrientation.Top,
show: true,
theme,
type: LegendType.Plain,
test('getLegendLayoutResult falls back to scroll when horizontal plain legends exceed two rows', () => {
expect(
getLegendLayoutResult({
chartHeight: 400,
chartWidth: 240,
legendItems: [
'This is a long legend label',
'Another long legend label',
'Third long legend label',
],
legendMargin: null,
orientation: LegendOrientation.Top,
show: true,
theme,
type: LegendType.Plain,
}),
).toEqual({
effectiveType: LegendType.Scroll,
});
expect(layout.effectiveType).toBe(LegendType.Plain);
// Each label is wider than the available width, so the row estimate
// overflows and falls back to one row per label: 20 + 2 * 24.
expect(layout.effectiveMargin).toBe(68);
});
test('getLegendLayoutResult bounds reserved margin for overflowing horizontal legends so the plot is not collapsed', () => {
const chartHeight = 200;
const layout = getLegendLayoutResult({
chartHeight,
chartWidth: 100,
legendItems: Array.from({ length: 100 }, (_, index) => `Series ${index}`),
legendMargin: null,
orientation: LegendOrientation.Top,
show: true,
theme,
type: LegendType.Plain,
test('getLegendLayoutResult falls back to scroll when a single horizontal plain legend item exceeds available width', () => {
expect(
getLegendLayoutResult({
chartHeight: 400,
chartWidth: 260,
legendItems: [
'This is a ridiculously long legend label that should not fit on one line',
],
legendMargin: null,
orientation: LegendOrientation.Top,
show: true,
theme,
type: LegendType.Plain,
}),
).toEqual({
effectiveType: LegendType.Scroll,
});
expect(layout.effectiveType).toBe(LegendType.Plain);
expect(Number.isFinite(layout.effectiveMargin)).toBe(true);
// 40% of the 200px chart height.
expect(layout.effectiveMargin).toBe(80);
});
test('getLegendLayoutResult bounds reserved margin for long vertical legend labels so the plot is not collapsed', () => {
const chartWidth = 1000;
const layout = getLegendLayoutResult({
chartHeight: 400,
chartWidth,
legendItems: ['A'.repeat(200)],
legendMargin: null,
orientation: LegendOrientation.Left,
show: true,
theme,
type: LegendType.Plain,
});
expect(layout.effectiveType).toBe(LegendType.Plain);
// 40% of the 1000px chart width.
expect(layout.effectiveMargin).toBe(400);
});
test('getLegendLayoutResult keeps plain when a single horizontal plain legend item exceeds available width', () => {
const layout = getLegendLayoutResult({
chartHeight: 400,
chartWidth: 260,
legendItems: [
'This is a ridiculously long legend label that should not fit on one line',
],
legendMargin: null,
orientation: LegendOrientation.Top,
show: true,
theme,
type: LegendType.Plain,
});
expect(layout.effectiveType).toBe(LegendType.Plain);
// Overflow fallback with a single label reserves no extra row: 20 + 0 * 24.
expect(layout.effectiveMargin).toBe(20);
});
test('getLegendLayoutResult keeps plain when reserved horizontal width reduces plain legend capacity', () => {
test('getLegendLayoutResult falls back to scroll when reserved horizontal width reduces plain legend capacity', () => {
const availableWidth = getHorizontalLegendAvailableWidth({
chartWidth: 265,
orientation: LegendOrientation.Top,
@@ -1167,38 +1130,38 @@ test('getLegendLayoutResult keeps plain when reserved horizontal width reduces p
zoomable: true,
});
const layout = getLegendLayoutResult({
availableWidth,
chartHeight: 400,
chartWidth: 265,
legendItems: ['Alpha', 'Beta', 'Gamma'],
legendMargin: null,
orientation: LegendOrientation.Top,
show: true,
theme,
type: LegendType.Plain,
expect(
getLegendLayoutResult({
availableWidth,
chartHeight: 400,
chartWidth: 265,
legendItems: ['Alpha', 'Beta', 'Gamma'],
legendMargin: null,
orientation: LegendOrientation.Top,
show: true,
theme,
type: LegendType.Plain,
}),
).toEqual({
effectiveType: LegendType.Scroll,
});
expect(layout.effectiveType).toBe(LegendType.Plain);
// 190px of available width wraps the three items onto three rows: 20 + 2 * 24.
expect(layout.effectiveMargin).toBe(68);
});
test('getLegendLayoutResult keeps plain when horizontal legend selectors alone exceed available width', () => {
const layout = getLegendLayoutResult({
chartHeight: 400,
chartWidth: 95,
legendItems: ['A'],
legendMargin: null,
orientation: LegendOrientation.Top,
show: true,
theme,
type: LegendType.Plain,
test('getLegendLayoutResult falls back to scroll when horizontal legend selectors alone exceed available width', () => {
expect(
getLegendLayoutResult({
chartHeight: 400,
chartWidth: 95,
legendItems: ['A'],
legendMargin: null,
orientation: LegendOrientation.Top,
show: true,
theme,
type: LegendType.Plain,
}),
).toEqual({
effectiveType: LegendType.Scroll,
});
expect(layout.effectiveType).toBe(LegendType.Plain);
// The selector alone overflows, so the fallback reserves one row: 20 + 0 * 24.
expect(layout.effectiveMargin).toBe(20);
});
test('getLegendLayoutResult keeps plain vertical legends when they fit within a single column', () => {
@@ -1239,87 +1202,56 @@ test('getLegendLayoutResult adds extra margin for wide vertical plain legends',
);
});
test('getLegendLayoutResult keeps plain when vertical plain legends exceed one column', () => {
const layout = getLegendLayoutResult({
chartHeight: 160,
chartWidth: 800,
legendItems: ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon'],
legendMargin: null,
orientation: LegendOrientation.Left,
show: true,
theme,
type: LegendType.Plain,
test('getLegendLayoutResult falls back to scroll when vertical plain legends exceed one column', () => {
expect(
getLegendLayoutResult({
chartHeight: 160,
chartWidth: 800,
legendItems: ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon'],
legendMargin: null,
orientation: LegendOrientation.Left,
show: true,
theme,
type: LegendType.Plain,
}),
).toEqual({
effectiveType: LegendType.Scroll,
});
expect(layout.effectiveType).toBe(LegendType.Plain);
// The widest label needs 128px, under the 170px base left padding, so the
// base padding wins.
expect(layout.effectiveMargin).toBe(170);
});
test('getLegendLayoutResult keeps plain when vertical plain legend selectors exceed available width', () => {
const layout = getLegendLayoutResult({
chartHeight: 400,
chartWidth: 300,
legendItems: ['A', 'B', 'C'],
legendMargin: null,
orientation: LegendOrientation.Left,
show: true,
theme,
type: LegendType.Plain,
test('getLegendLayoutResult falls back to scroll when vertical plain legend selectors exceed available width', () => {
expect(
getLegendLayoutResult({
chartHeight: 400,
chartWidth: 300,
legendItems: ['A', 'B', 'C'],
legendMargin: null,
orientation: LegendOrientation.Left,
show: true,
theme,
type: LegendType.Plain,
}),
).toEqual({
effectiveType: LegendType.Scroll,
});
expect(layout.effectiveType).toBe(LegendType.Plain);
// The 128px selector requirement is clamped to 120px (40% of the 300px
// width) and both stay under the 170px base left padding.
expect(layout.effectiveMargin).toBe(170);
});
test('getLegendLayoutResult honors an explicit List selection with many series', () => {
const manyItems = Array.from({ length: 40 }, (_, i) => `Series ${i + 1}`);
const horizontal = getLegendLayoutResult({
chartHeight: 400,
chartWidth: 800,
legendItems: manyItems,
legendMargin: null,
orientation: LegendOrientation.Top,
show: true,
theme,
type: LegendType.Plain,
});
expect(horizontal.effectiveType).toBe(LegendType.Plain);
const vertical = getLegendLayoutResult({
chartHeight: 160,
chartWidth: 800,
legendItems: manyItems,
legendMargin: null,
orientation: LegendOrientation.Left,
show: true,
theme,
type: LegendType.Plain,
});
expect(vertical.effectiveType).toBe(LegendType.Plain);
});
test('getLegendLayoutResult counts empty-string legend labels when estimating layout', () => {
const layout = getLegendLayoutResult({
chartHeight: 400,
chartWidth: 116,
legendItems: ['', 'A', 'B', 'C', 'D'],
legendMargin: null,
orientation: LegendOrientation.Top,
show: true,
showSelectors: false,
theme,
type: LegendType.Plain,
expect(
getLegendLayoutResult({
chartHeight: 400,
chartWidth: 116,
legendItems: ['', 'A', 'B', 'C', 'D'],
legendMargin: null,
orientation: LegendOrientation.Top,
show: true,
showSelectors: false,
theme,
type: LegendType.Plain,
}),
).toEqual({
effectiveType: LegendType.Scroll,
});
expect(layout.effectiveType).toBe(LegendType.Plain);
// The empty label still occupies a slot, wrapping five items onto three
// rows: 20 + 2 * 24.
expect(layout.effectiveMargin).toBe(68);
});
test('resolveLegendLayout returns both raw and effective legend layout values', () => {
@@ -32,7 +32,7 @@
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.27.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.2",
"react-map-gl": "^8.1.1",
"supercluster": "^8.0.1"
},
"peerDependencies": {
@@ -1150,15 +1150,13 @@ export default function TableChart<D extends DataRecord = DataRecord>(
text-align: ${sharedStyle.textAlign};
white-space: ${value instanceof Date ? 'nowrap' : undefined};
position: relative;
font-weight: ${
color ? `${theme.fontWeightBold}` : `${theme.fontWeightNormal}`
};
font-weight: ${color
? `${theme.fontWeightBold}`
: `${theme.fontWeightNormal}`};
background: ${backgroundColor || undefined};
padding-left: ${
column.isChildColumn
? `${theme.sizeUnit * 5}px`
: `${theme.sizeUnit}px`
};
padding-left: ${column.isChildColumn
? `${theme.sizeUnit * 5}px`
: `${theme.sizeUnit}px`};
`;
const cellBarStyles = css`
@@ -1166,11 +1164,10 @@ export default function TableChart<D extends DataRecord = DataRecord>(
height: 100%;
display: block;
top: 0;
${
valueRange &&
typeof value === 'number' &&
valueRangeFlag &&
`
${valueRange &&
typeof value === 'number' &&
valueRangeFlag &&
`
width: ${`${cellWidth({
value: value as number,
valueRange,
@@ -1189,18 +1186,15 @@ export default function TableChart<D extends DataRecord = DataRecord>(
theme,
})
};
`
}
`}
`;
let arrowStyles = css`
color: ${
basicColorFormatters &&
basicColorFormatters[row.index][originKey]?.arrowColor ===
ColorSchemeEnum.Green
? theme.colorSuccess
: theme.colorError
};
color: ${basicColorFormatters &&
basicColorFormatters[row.index][originKey]?.arrowColor ===
ColorSchemeEnum.Green
? theme.colorSuccess
: theme.colorError};
margin-right: ${theme.sizeUnit}px;
`;
@@ -1209,12 +1203,10 @@ export default function TableChart<D extends DataRecord = DataRecord>(
basicColorColumnFormatters?.length > 0
) {
arrowStyles = css`
color: ${
basicColorColumnFormatters[row.index][column.key]
?.arrowColor === ColorSchemeEnum.Green
? theme.colorSuccess
: theme.colorError
};
color: ${basicColorColumnFormatters[row.index][column.key]
?.arrowColor === ColorSchemeEnum.Green
? theme.colorSuccess
: theme.colorError};
margin-right: ${theme.sizeUnit}px;
`;
}
@@ -53,7 +53,7 @@
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.4",
"prop-types": "^15.8.1",
"react-map-gl": "^8.1.2",
"react-map-gl": "^8.1.1",
"tinycolor2": "^1.6.0",
"urijs": "^1.19.11",
"xss": "^1.0.15"
@@ -529,66 +529,4 @@ describe('Polygon transformProps', () => {
expect(features[0]?.extraProps?.['SUM(population)']).toBe(50000);
expect(features[0]?.metrics?.['SUM(population)']).toBe(50000);
});
// Regression test for #33669: a boundary column literally named "polygon"
// (the same key this transform uses internally for the parsed geometry)
// reportedly broke rendering, because the raw column value could
// theoretically collide with the `polygon` key this function builds on
// each feature. `line_column` is excluded before spreading a record's
// other properties onto the feature, and the parsed `polygon` key is
// assigned last in the returned object literal, so it should always win
// over anything copied from the raw record, even when they share a name.
//
// The fixture mirrors the GeoJSON `Feature` shape from the CSV attached to
// the issue (a `Feature` with nested `geometry.coordinates`), not a bare
// coordinate array, since those two shapes take different parsing paths
// in `getPolygonCoordinateParts`. Both reported column-name spellings,
// "polygon" and "Polygon", are covered.
test.each(['polygon', 'Polygon'])(
'should correctly parse polygon geometry when the boundary column is itself named "%s"',
columnName => {
const collidingColumnNameProps = {
...mockChartProps,
rawFormData: {
...mockChartProps.rawFormData,
line_column: columnName,
},
queriesData: [
{
data: [
{
[columnName]: JSON.stringify({
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-122.4, 37.8],
[-122.3, 37.8],
[-122.3, 37.9],
[-122.4, 37.9],
],
],
},
properties: { NOM_COM: 'TEST' },
}),
population: 50000,
},
],
},
],
};
const result = transformProps(collidingColumnNameProps as ChartProps);
const features = result.payload.data.features as PolygonFeature[];
expect(features).toHaveLength(1);
expect(features[0]?.polygon).toEqual([
[-122.4, 37.8],
[-122.3, 37.8],
[-122.3, 37.9],
[-122.4, 37.9],
]);
},
);
});
@@ -370,9 +370,9 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
<Icons.SortAscendingOutlined
iconSize="m"
css={css`
color: ${
sortedTables[data.id] ? theme.colorPrimary : 'inherit'
};
color: ${sortedTables[data.id]
? theme.colorPrimary
: 'inherit'};
`}
/>
}
@@ -188,21 +188,17 @@ export const EmptyFolderDropZone = styled.div<{
margin-left: ${depth * FOLDER_INDENTATION_WIDTH + ITEM_INDENTATION_WIDTH}px;
padding: ${theme.paddingLG}px;
border: 2px dashed
${
isOver
? isForbidden
? theme.colorError
: theme.colorPrimary
: 'transparent'
};
border-radius: ${theme.borderRadius}px;
background: ${
isOver
${isOver
? isForbidden
? theme.colorErrorBg
: theme.colorPrimaryBg
: 'transparent'
};
? theme.colorError
: theme.colorPrimary
: 'transparent'};
border-radius: ${theme.borderRadius}px;
background: ${isOver
? isForbidden
? theme.colorErrorBg
: theme.colorPrimaryBg
: 'transparent'};
text-align: center;
transition: all 0.2s ease-in-out;
cursor: ${isOver && isForbidden ? 'not-allowed' : 'default'};
@@ -106,9 +106,9 @@ const OptionItem = styled.li<{ $active: boolean }>`
transition: background 0.15s;
&:hover {
background: ${
$active ? theme.colorPrimaryBgHover : theme.colorFillTertiary
};
background: ${$active
? theme.colorPrimaryBgHover
: theme.colorFillTertiary};
outline: 2px solid ${theme.colorPrimary};
outline-offset: -2px;
}
@@ -144,14 +144,12 @@ export const StatusIndicatorDot: FC<StatusIndicatorDotProps> = ({
background-color ${theme.motionDurationMid} ease-in-out,
border-color ${theme.motionDurationMid} ease-in-out;
border: ${statusConfig.needsBorder ? '1px solid' : 'none'};
border-color: ${
statusConfig.needsBorder ? statusConfig.outlineColor : 'transparent'
};
box-shadow: ${
statusConfig.needsBorder
? 'none'
: `0 0 0 2px ${theme.colorBgContainer}`
};
border-color: ${statusConfig.needsBorder
? statusConfig.outlineColor
: 'transparent'};
box-shadow: ${statusConfig.needsBorder
? 'none'
: `0 0 0 2px ${theme.colorBgContainer}`};
margin-left: ${theme.marginXS}px;
margin-right: ${theme.marginXS}px;
cursor: help;
@@ -30,12 +30,10 @@ const DragHandleContainer = styled.div<{ position: 'left' | 'top' }>`
height: ${theme.sizeUnit * 5}px;
overflow: hidden;
cursor: move;
${
position === 'top' &&
css`
transform: rotate(90deg);
`
}
${position === 'top' &&
css`
transform: rotate(90deg);
`}
& path {
fill: ${theme.colorIcon};
}
@@ -46,36 +46,32 @@ const ButtonsContainer = styled.div<{ isVertical: boolean }>`
${({ theme, isVertical }) => css`
display: flex;
${
isVertical
? css`
flex-direction: column;
align-items: center;
position: sticky;
z-index: 100;
bottom: 0;
padding: ${theme.sizeUnit * 4}px;
padding-top: ${theme.sizeUnit * 6}px;
background: linear-gradient(
${tinycolor(theme.colorBgLayout).setAlpha(0).toRgbString()},
${theme.colorBgContainer} 20%
);
`
: css`
align-items: center;
margin-left: auto;
`
}
${isVertical
? css`
flex-direction: column;
align-items: center;
position: sticky;
z-index: 100;
bottom: 0;
padding: ${theme.sizeUnit * 4}px;
padding-top: ${theme.sizeUnit * 6}px;
background: linear-gradient(
${tinycolor(theme.colorBgLayout).setAlpha(0).toRgbString()},
${theme.colorBgContainer} 20%
);
`
: css`
align-items: center;
margin-left: auto;
`}
`}
`;
const applyButtonStyle = (theme: SupersetTheme, isVertical: boolean) => css`
${
isVertical &&
css`
margin-bottom: ${theme.sizeUnit * 3}px;
`
}
${isVertical &&
css`
margin-bottom: ${theme.sizeUnit * 3}px;
`}
`;
const clearAllButtonStyle = (theme: SupersetTheme, isVertical: boolean) => css`
@@ -92,13 +88,11 @@ const clearAllButtonStyle = (theme: SupersetTheme, isVertical: boolean) => css`
color: ${theme.colorTextDisabled};
}
${
!isVertical &&
css`
text-transform: capitalize;
font-weight: ${theme.fontWeightNormal};
`
}
${!isVertical &&
css`
text-transform: capitalize;
font-weight: ${theme.fontWeightNormal};
`}
}
`;
@@ -63,16 +63,14 @@ const CrossFilter = (props: {
<div
key={`${filter.name}${filter.emitterId}`}
css={css`
${
orientation === FilterBarOrientation.Vertical
? `
${orientation === FilterBarOrientation.Vertical
? `
display: block;
margin-bottom: ${theme.sizeUnit * 4}px;
`
: `
: `
display: flex;
`
}
`}
`}
>
<CrossFilterTitle
@@ -91,23 +89,21 @@ const CrossFilter = (props: {
<div
data-test="cross-filters-divider"
css={css`
${
orientation === FilterBarOrientation.Horizontal
? `
${orientation === FilterBarOrientation.Horizontal
? `
width: 1px;
height: 22px;
margin-left: ${theme.sizeUnit * 4}px;
margin-right: ${theme.sizeUnit}px;
flex-shrink: 0;
`
: `
: `
width: 100%;
height: 1px;
display: block;
margin-bottom: ${theme.sizeUnit * 4}px;
margin-top: ${theme.sizeUnit * 4}px;
`
}
`}
background: ${theme.colorSplit};
`}
/>
@@ -67,15 +67,13 @@ const CrossFilterTag = (props: {
return (
<StyledTag
css={css`
${
orientation === FilterBarOrientation.Vertical
? `
${orientation === FilterBarOrientation.Vertical
? `
margin-top: ${theme.sizeUnit * 2}px;
`
: `
: `
margin-left: ${theme.sizeUnit * 2}px;
`
}
`}
`}
closable
onClose={() => removeCrossFilter(filter.emitterId)}
@@ -63,11 +63,9 @@ const CrossFilterChartTitle = (props: {
<Tooltip title={titleIsTruncated ? title : null}>
<span
css={css`
max-width: ${
orientation === FilterBarOrientation.Vertical
? `${theme.sizeUnit * 45}px`
: `${theme.sizeUnit * 15}px`
};
max-width: ${orientation === FilterBarOrientation.Vertical
? `${theme.sizeUnit * 45}px`
: `${theme.sizeUnit * 15}px`};
line-height: 1.4;
${ellipsisCss}
`}
@@ -64,11 +64,9 @@ const UrlFilterTag = (props: {
return (
<StyledTag
css={css`
${
orientation === FilterBarOrientation.Vertical
? `margin-top: ${theme.sizeUnit * 2}px;`
: `margin-left: ${theme.sizeUnit * 2}px;`
}
${orientation === FilterBarOrientation.Vertical
? `margin-top: ${theme.sizeUnit * 2}px;`
: `margin-left: ${theme.sizeUnit * 2}px;`}
`}
closable
onClose={() => onRemove(filter)}
@@ -1998,12 +1998,10 @@ const FiltersConfigForm = (
iconSize="xl"
iconColor={theme.colorPrimary}
css={css`
margin-left: ${
theme.sizeUnit * 2
}px;
margin-top: ${
theme.sizeUnit * 1.5
}px;
margin-left: ${theme.sizeUnit *
2}px;
margin-top: ${theme.sizeUnit *
1.5}px;
`}
onClick={() => refreshHandler(true)}
/>
@@ -86,11 +86,9 @@ export default function VerticalRadioControl({
css={css`
margin-left: 4px;
font-size: 12px;
color: ${
disabled
? theme.colorTextDisabled
: theme.colorTextTertiary
};
color: ${disabled
? theme.colorTextDisabled
: theme.colorTextTertiary};
cursor: help;
`}
/>
@@ -97,34 +97,30 @@ export const VizTile = ({
white-space: nowrap;
overflow: hidden;
max-width: fit-content;
${
!isActive &&
css`
flex-shrink: 0;
width: ${theme.sizeUnit * 6}px;
background-color: transparent;
transition: none;
&:hover svg path {
fill: ${theme.colorPrimary};
transition: fill ${theme.motionDurationMid} ease-out;
}
`
}
${!isActive &&
css`
flex-shrink: 0;
width: ${theme.sizeUnit * 6}px;
background-color: transparent;
transition: none;
&:hover svg path {
fill: ${theme.colorPrimary};
transition: fill ${theme.motionDurationMid} ease-out;
}
`}
${
isActive &&
css`
width: 100%;
background-color: ${theme.colorBgLayout};
transition:
width ${TILE_TRANSITION_TIME} ease-out,
background-color ${TILE_TRANSITION_TIME} ease-out;
cursor: default;
svg path {
fill: ${theme.colorPrimary};
}
`
}
${isActive &&
css`
width: 100%;
background-color: ${theme.colorBgLayout};
transition:
width ${TILE_TRANSITION_TIME} ease-out,
background-color ${TILE_TRANSITION_TIME} ease-out;
cursor: default;
svg path {
fill: ${theme.colorPrimary};
}
`}
`}
>
<span
@@ -104,12 +104,10 @@ const StyledMenuItem = styled.div<{ disabled?: boolean }>`
color: ${!disabled && theme.colorPrimary};
cursor: ${!disabled ? 'pointer' : 'not-allowed'};
}
${
disabled &&
css`
color: ${theme.colorTextDisabled};
`
}
${disabled &&
css`
color: ${theme.colorTextDisabled};
`}
`}
`;
+13 -5
View File
@@ -17,7 +17,7 @@
* under the License.
*/
const zlib = require('zlib');
const { ZSTDDecompress } = require('simple-zstd');
const { decompress: zstdDecompress } = require('simple-zstd');
const yargs = require('yargs');
const { hideBin } = require('yargs/helpers');
@@ -117,13 +117,13 @@ function copyHeaders(originalResponse, response) {
* Manipulate HTML server response to replace asset files with
* local webpack-dev-server build.
*/
function processHTML(proxyResponse, response) {
async function processHTML(proxyResponse, response) {
let body = Buffer.from([]);
let originalResponse = proxyResponse;
let uncompress;
const responseEncoding = originalResponse.headers['content-encoding'];
// decode GZIP response
let uncompress;
if (responseEncoding === 'gzip') {
uncompress = zlib.createGunzip();
} else if (responseEncoding === 'br') {
@@ -131,7 +131,7 @@ function processHTML(proxyResponse, response) {
} else if (responseEncoding === 'deflate') {
uncompress = zlib.createInflate();
} else if (responseEncoding === 'zstd') {
uncompress = ZSTDDecompress();
uncompress = await zstdDecompress();
}
if (uncompress) {
originalResponse.pipe(uncompress);
@@ -178,7 +178,15 @@ module.exports = newManifest => {
// For HTML responses, flush headers before processing starts
// processHTML sets up async handlers that will call response.end()
response.flushHeaders();
processHTML(proxyResponse, response);
processHTML(proxyResponse, response).catch(e => {
// eslint-disable-next-line no-console
console.error(`Error requesting ${request.path} from proxy:`, e);
if (!response.writableEnded) {
response.end(
`Error requesting ${request.path} from proxy: ${e.message}`,
);
}
});
} else {
const isCSV = (proxyResponse.headers['content-type'] || '').includes(
'text/csv',
+9 -11
View File
@@ -689,14 +689,13 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
# can_copy_clipboard) instead of the single can_csv permission
# @lifecycle: development
"GRANULAR_EXPORT_CONTROLS": False,
# Temporary rollout / kill-switch gate for soft delete (off = legacy hard
# delete). An emergency stop, not a clean rollback: flipping ON->OFF
# resurrects already-soft-deleted rows. Retained through this release as
# the move-back lever; removed (along with its two gate points —
# BaseDAO.delete routing and the do_orm_execute visibility listener) once
# post-flip confidence is established.
# Temporary rollout / kill-switch gate for soft delete (default off = legacy
# hard delete). An emergency stop, not a clean rollback: flipping ON->OFF
# resurrects already-soft-deleted rows. Removed (along with its two gate
# points — BaseDAO.delete routing and the do_orm_execute visibility listener)
# once soft delete is stable.
# @lifecycle: development
"SOFT_DELETE": True,
"SOFT_DELETE": False,
# Enable semantic layers and show semantic views alongside datasets
# @lifecycle: development
"SEMANTIC_LAYERS": False,
@@ -1004,11 +1003,10 @@ USER_AGENT_FUNC: Callable[[Database, utils.QuerySource | None], str] | None = No
FEATURE_FLAGS: dict[str, bool] = {}
# Retention policy for soft-deleted dashboards, charts, and datasets. A value of
# zero disables scheduled purging. Purging is live by default, so the retention
# promise above is real on a stock deployment; set SOFT_DELETE_PURGE_DRY_RUN back
# to True to have the task log ``would_purge`` counts without deleting anything.
# zero disables scheduled purging. Dry-run mode is enabled by default so operators
# must explicitly opt in to irreversible deletion.
SOFT_DELETE_RETENTION_DAYS: int = 30
SOFT_DELETE_PURGE_DRY_RUN: bool = False
SOFT_DELETE_PURGE_DRY_RUN: bool = True
# A function that receives a dict of all feature flags
# (DEFAULT_FEATURE_FLAGS merged with FEATURE_FLAGS)
+1 -12
View File
@@ -2876,11 +2876,6 @@ class BasicParametersMixin:
# for Postgres this would be `{"sslmode": "verify-ca"}`, eg.
encryption_parameters: dict[str, str] = {}
# query parameter to explicitly disable encryption, for drivers that do not
# treat the absence of `encryption_parameters` as an unencrypted connection
# for Databend this would be `{"sslmode": "disable"}`, eg.
encryption_disable_parameters: dict[str, str] = {}
@classmethod
def build_sqlalchemy_uri( # pylint: disable=unused-argument
cls,
@@ -2896,8 +2891,6 @@ class BasicParametersMixin:
"Unable to build a URL with encryption enabled"
)
query.update(cls.encryption_parameters)
else:
query.update(cls.encryption_disable_parameters)
return str(
URL.create(
@@ -2916,14 +2909,10 @@ class BasicParametersMixin:
cls, uri: str, encrypted_extra: dict[str, Any] | None = None
) -> BasicParametersType:
url = make_url_safe(uri)
encryption_items = [
*cls.encryption_parameters.items(),
*cls.encryption_disable_parameters.items(),
]
query = {
key: value
for (key, value) in url.query.items()
if (key, value) not in encryption_items
if (key, value) not in cls.encryption_parameters.items()
}
encryption = all(
item in url.query.items() for item in cls.encryption_parameters.items()
+20 -69
View File
@@ -195,15 +195,10 @@ class DatabendEngineSpec(BasicParametersMixin, DatabendBaseEngineSpec):
supports_file_upload = False
sqlalchemy_uri_placeholder = (
"databend://user:password@host[:port][/dbname][?sslmode=value&=value...]"
"databend://user:password@host[:port][/dbname][?secure=value&=value...]"
)
parameters_schema = DatabendParametersSchema()
encryption_parameters = {"sslmode": "require"}
encryption_disable_parameters = {"sslmode": "disable"}
# every ``sslmode`` the driver resolves to an https scheme; it accepts both
# spellings, so a hand-written ``sslmode=enable`` must not read as plaintext
encryption_sslmodes = frozenset({"require", "enable"})
encryption_parameters = {"secure": "true"}
metadata = {
"description": (
@@ -219,7 +214,7 @@ class DatabendEngineSpec(BasicParametersMixin, DatabendBaseEngineSpec):
],
"pypi_packages": ["databend-sqlalchemy"],
"connection_string": (
"databend://{username}:{password}@{host}:{port}/{database}?sslmode=require"
"databend://{username}:{password}@{host}:{port}/{database}?secure=true"
),
"default_port": 443,
"parameters": {
@@ -264,73 +259,29 @@ class DatabendEngineSpec(BasicParametersMixin, DatabendBaseEngineSpec):
@classmethod
def build_sqlalchemy_uri(
cls,
parameters: BasicParametersType,
encrypted_extra: dict[str, str] | None = None,
cls, parameters: BasicParametersType, *_args: dict[str, str] | None
) -> str:
"""
Build a Databend URI, always stating the TLS mode explicitly.
The driver honours ``sslmode`` rather than inferring TLS from the port,
so an unencrypted connection needs ``sslmode=disable`` spelled out
rather than simply omitting the encryption parameters.
"""
query = parameters.get("query", {}).copy()
query.update(
cls.encryption_parameters
if parameters.get("encryption")
else cls.encryption_disable_parameters
)
return str(
URL.create(
cls.engine,
username=parameters.get("username"),
password=parameters.get("password"),
host=parameters.get("host"),
port=parameters.get("port"),
database=parameters.get("database") or "__default__",
query=query,
)
)
@classmethod
def _encryption_from_tls_parameters(
cls, sslmode: str | None, secure: str | None
) -> bool:
"""
Resolve whether a connection is encrypted from either TLS spelling.
``databend-py`` parsed the legacy ``secure`` value with ``asbool``, so
casing is not significant in either parameter.
"""
if sslmode is not None:
return sslmode.lower() in cls.encryption_sslmodes
if secure is not None:
return secure.lower() == "true"
return False
url_params = parameters.copy()
if url_params.get("encryption"):
query = parameters.get("query", {}).copy()
query.update(cls.encryption_parameters)
url_params["query"] = query
if not url_params.get("database"):
url_params["database"] = "__default__"
url_params.pop("encryption", None)
return str(URL(f"{cls.engine}", **url_params))
@classmethod
def get_parameters_from_uri(
cls,
uri: str,
encrypted_extra: dict[str, Any] | None = None,
cls, uri: str, *_args: dict[str, Any] | None
) -> BasicParametersType:
"""
Decompose a Databend URI into individual connection parameters.
The legacy ``secure`` parameter is still recognised so that connections
stored before the move to ``sslmode`` repopulate the form correctly. Its
values were parsed as booleans by the previous driver, so casing is not
significant in either parameter. Both are always removed, so a URI
carrying the legacy and the current spelling at once cannot leak one of
them back into the connection as a user-supplied extra parameter.
"""
url = make_url_safe(uri)
query = dict(url.query)
encryption = cls._encryption_from_tls_parameters(
query.pop("sslmode", None), query.pop("secure", None)
)
query = url.query
if "secure" in query:
encryption = url.query.get("secure") == "true"
query.pop("secure")
else:
encryption = False
return BasicParametersType(
username=url.username,
password=url.password,
+66
View File
@@ -43,6 +43,7 @@ from superset.db_engine_specs.base import (
from superset.db_engine_specs.hive import HiveEngineSpec
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import OAuth2Error
from superset.models.sql_lab import Query
from superset.utils import json
from superset.utils.core import get_user_agent, QuerySource
from superset.utils.network import is_hostname_valid, is_port_open
@@ -775,6 +776,11 @@ class DatabricksPythonConnectorEngineSpec(DatabricksDynamicBaseEngineSpec):
parameters_schema = DatabricksPythonConnectorSchema()
# The driver only populates `cursor.active_command_id` once a statement has
# actually been executed, so the cancel id can't be captured up front like
# it can for engines where it's tied to the session rather than the query.
has_query_id_before_execute = False
sqlalchemy_uri_placeholder = (
"databricks://token:{access_token}@{host}:{port}?http_path={http_path}"
"&catalog={default_catalog}&schema={default_schema}"
@@ -957,6 +963,66 @@ class DatabricksPythonConnectorEngineSpec(DatabricksDynamicBaseEngineSpec):
return uri, connect_args
@classmethod
def get_cancel_query_id(cls, cursor: Any, query: Query) -> str | None:
"""
Capture a cancel id for the query that was just executed on ``cursor``.
Cancellation is only supported when the connection uses Databricks'
Statement Execution API (SEA) backend, i.e. ``use_sea=True`` was set in
the database's connection parameters. A SEA statement id is a plain
string that can be cancelled later from a brand-new cursor/connection,
which matches how Superset issues cancellation (a fresh connection, not
the one that ran the query).
The default (Thrift) backend has no equivalent public mechanism: the
driver can only cancel a Thrift command via the live ``CommandId`` held
by the executing cursor, which includes a secret that's never exposed
through any public/documented accessor (``cursor.query_id`` only
returns the operation's GUID). Rather than reconstructing that secret
from the driver's private internals, this returns ``None`` for
Thrift-backed connections so the "stop query" action fails explicitly
instead of silently no-oping.
:param cursor: Cursor instance in which the query was just executed
:param query: Query instance
:return: SEA statement id, or None if cancellation isn't supported
"""
command_id = getattr(cursor, "active_command_id", None)
if command_id is None:
return None
session = getattr(getattr(cursor, "connection", None), "session", None)
if not getattr(session, "use_sea", False):
return None
return command_id.to_sea_statement_id()
@classmethod
def cancel_query(cls, cursor: Any, query: Query, cancel_query_id: str) -> bool:
"""
Cancel a query in the underlying database.
Only implemented for SEA (Statement Execution API) connections; see
``get_cancel_query_id``. Any error raised while attempting the cancel
(e.g. the fresh cursor/connection itself failing) is allowed to
propagate rather than being reported as a successful or failed cancel.
:param cursor: New cursor instance to the db of the query
:param query: Query instance
:param cancel_query_id: SEA statement id, as returned by
``get_cancel_query_id``
:return: True if the cancel request was submitted successfully
"""
if not cls.validate_cancel_query_id(cancel_query_id, r"^[a-zA-Z0-9-]+$"):
return False
from databricks.sql.backend.types import CommandId
cursor.active_command_id = CommandId.from_sea_statement_id(cancel_query_id)
cursor.cancel()
return True
# TODO: remove once we've upgraded to SQLAlchemy>=2.0 and databricks-sql-python>=3.x
monkeypatch_dialect()
+1 -16
View File
@@ -165,22 +165,7 @@ def main() -> None:
if transport == "streamable-http":
host = os.environ.get("FASTMCP_HOST", "127.0.0.1")
port = int(os.environ.get("FASTMCP_PORT", "5008"))
# See MCP_STATELESS_HTTP's docstring in mcp_config.py -- stateless
# mode races a tool's progress notifications against the
# transport teardown that follows its HTTP request.
from superset.mcp_service.flask_singleton import get_flask_app
from superset.mcp_service.mcp_config import MCP_STATELESS_HTTP
stateless_http = get_flask_app().config.get(
"MCP_STATELESS_HTTP", MCP_STATELESS_HTTP
)
mcp.run(
transport=transport,
host=host,
port=port,
stateless_http=stateless_http,
)
mcp.run(transport=transport, host=host, port=port, stateless_http=True)
else:
mcp.run(transport=transport)
-19
View File
@@ -66,24 +66,6 @@ MCP_BUG_REPORT_CONTACT: str | None = None
# MCP Debug mode - shows suppressed initialization output in stdio mode
MCP_DEBUG = False
# Streamable-HTTP session mode used by run_server() (superset/mcp_service/server.py)
# and the CLI entrypoint (superset/mcp_service/__main__.py).
#
# True (default): each HTTP request gets a fresh, ephemeral transport that is
# torn down as soon as that single request/response completes, while the
# tool call it started keeps running as a background task. If a client gives
# up on a still-running call (its own timeout, a reconnect, etc.), the next
# progress notification that tool sends hits the now-closed transport and
# raises anyio.ClosedResourceError/BrokenResourceError -- crashing that
# session and disconnecting other concurrent clients on the same worker.
#
# False: sessions are tracked by Mcp-Session-Id and the transport stays alive
# for the session's lifetime, so a client disconnecting mid-call no longer
# crashes the tool. This requires session-affinity routing on Mcp-Session-Id
# at the mesh/ingress layer for multi-pod deployments -- a client's follow-up
# requests must land on the pod that created its session.
MCP_STATELESS_HTTP = True
# MCP RBAC - when True, tools with class_permission_name are checked
# against the FAB security_manager before execution.
MCP_RBAC_ENABLED = True
@@ -734,7 +716,6 @@ def get_mcp_config(app_config: dict[str, Any] | None = None) -> dict[str, Any]:
"WEBDRIVER_BASEURL": WEBDRIVER_BASEURL,
"WEBDRIVER_BASEURL_USER_FRIENDLY": WEBDRIVER_BASEURL_USER_FRIENDLY,
"MCP_DEBUG": MCP_DEBUG,
"MCP_STATELESS_HTTP": MCP_STATELESS_HTTP,
"MCP_RBAC_ENABLED": MCP_RBAC_ENABLED,
"MCP_DISABLED_TOOLS": set(MCP_DISABLED_TOOLS),
"MCP_DISABLED_CHART_PLUGINS": MCP_DISABLED_CHART_PLUGINS,
+3 -18
View File
@@ -36,7 +36,6 @@ from superset.mcp_service.app import create_mcp_app, init_fastmcp_server
from superset.mcp_service.jwt_verifier import BrowserHelloMiddleware
from superset.mcp_service.mcp_config import (
get_mcp_factory_config,
MCP_STATELESS_HTTP,
MCP_STORE_CONFIG,
MCP_TOOL_SEARCH_CONFIG,
)
@@ -945,11 +944,7 @@ def run_server(
Uses streamable-http transport for HTTP server mode.
For multi-pod deployments, configure MCP_EVENT_STORE_CONFIG with Redis URL
to share session state across pods. If MCP_STATELESS_HTTP is also set to
False (see its docstring in mcp_config.py), sessions are stateful and
multi-pod additionally requires session-affinity routing on
Mcp-Session-Id at the mesh/ingress layer -- otherwise a session's
follow-up requests can land on a pod that never created it.
to share session state across pods.
Args:
host: Host to bind to
@@ -1030,23 +1025,13 @@ def run_server(
try:
logging.info("Starting FastMCP on %s:%s", host, port)
# See MCP_STATELESS_HTTP's docstring in mcp_config.py: stateless
# mode races a tool's progress notifications against the
# transport teardown that follows its HTTP request, crashing the
# session if a client disconnects mid-call.
stateless_http = (
flask_app.config.get("MCP_STATELESS_HTTP", MCP_STATELESS_HTTP)
if flask_app is not None
else MCP_STATELESS_HTTP
)
if event_store is not None:
# Multi-pod: Use http_app with Redis EventStore, run with uvicorn
logging.info("Running in multi-pod mode with Redis EventStore")
app = mcp_instance.http_app(
transport="streamable-http",
event_store=event_store,
stateless_http=stateless_http,
stateless_http=True,
middleware=starlette_middleware,
)
uvicorn.run(app, host=host, port=port)
@@ -1057,7 +1042,7 @@ def run_server(
transport="streamable-http",
host=host,
port=port,
stateless_http=stateless_http,
stateless_http=True,
middleware=starlette_middleware,
)
except Exception as e:
@@ -1,163 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""migrate Databend connections to an explicit sslmode
``databend-sqlalchemy`` moved from the pure-Python ``databend-py`` client to a
Rust core, and the two disagree about TLS in ways that silently break stored
connections.
``databend-py`` took ``secure``, defaulting to ``False``, and selected an
``http`` scheme unless it was set. The Rust core takes ``sslmode`` and defaults
the scheme to ``https``; an unrecognised parameter is not rejected but stored as
a session variable. A Databend connection therefore keeps whatever ``secure``
value it was saved with, has it quietly ignored, and switches to TLS against a
server that may not speak it.
Both affected shapes are rewritten to the parameter the driver now reads:
* an explicit ``secure`` becomes the equivalent ``sslmode``. Values were parsed
as booleans by ``databend-py``, so ``secure=True`` counted as encrypted and
casing is not significant here either.
* no TLS parameter at all becomes ``sslmode=disable``. These connections were
plaintext under ``databend-py``'s ``http`` default -- Superset only ever wrote
``secure=true``, never ``secure=false`` -- so this preserves how they have
always behaved rather than downgrading them. Leaving them untouched would let
the new ``https`` default break exactly the connections this migration exists
to protect.
The query string is edited one parameter at a time instead of being parsed and
re-rendered through ``URL``. ``URL.render_as_string`` sorts the query keys and
re-encodes every value, which would reorder and rewrite unrelated parameters on
every row it touched; editing in place leaves everything but the TLS parameter
byte-identical.
``downgrade`` restores ``secure``, which is semantically but not textually exact:
a row that had no TLS parameter before ``upgrade`` comes back as ``secure=false``
rather than bare, and non-canonical casing is normalised. Both forms mean the
same thing to ``databend-py``.
Revision ID: c4a1b8e2d739
Revises: 1a27941d5352
Create Date: 2026-08-06 00:00:00.000000
"""
from alembic import op
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import declarative_base
from superset import db
from superset.migrations.shared.utils import paginated_update
# revision identifiers, used by Alembic.
revision = "c4a1b8e2d739"
down_revision = "1a27941d5352"
Base = declarative_base()
_TLS_PARAMETERS = ("sslmode", "secure")
# (parameter, lower-cased value) -> replacement parameter and value
_TO_SSLMODE = {
("secure", "true"): ("sslmode", "require"),
("secure", "false"): ("sslmode", "disable"),
}
_TO_SECURE = {
("sslmode", "require"): ("secure", "true"),
("sslmode", "enable"): ("secure", "true"),
("sslmode", "disable"): ("secure", "false"),
}
class Database(Base): # type: ignore
__tablename__ = "dbs"
id = Column(Integer, primary_key=True)
sqlalchemy_uri = Column(String(1024), nullable=False)
def _split_query(uri: str) -> tuple[str, list[str]]:
"""
Separate a URI from its query parameters.
The delimiter is searched for after the credentials, which are not escaped
for ``?`` and would otherwise be mistaken for the start of the query.
"""
start = uri.find("?", uri.rfind("@") + 1)
if start == -1:
return uri, []
return uri[:start], uri[start + 1 :].split("&")
def _rewrite_query_parameters(
uri: str,
replacements: dict[tuple[str, str], tuple[str, str]],
default: tuple[str, str] | None = None,
) -> str | None:
"""
Swap known TLS parameters in a URI's query string, preserving the rest.
``default`` is appended when the URI carries no TLS parameter at all.
Returns ``None`` when nothing matched, so callers can skip the write.
"""
base, pairs = _split_query(uri)
changed = False
tls_parameter_seen = False
rewritten = []
for pair in pairs:
key, _, value = pair.partition("=")
tls_parameter_seen = tls_parameter_seen or key in _TLS_PARAMETERS
if replacement := replacements.get((key, value.lower())):
rewritten.append("=".join(replacement))
changed = True
else:
rewritten.append(pair)
if default and not tls_parameter_seen:
rewritten.append("=".join(default))
changed = True
if not changed:
return None
return f"{base}?{'&'.join(rewritten)}" if rewritten else base
def _migrate(
replacements: dict[tuple[str, str], tuple[str, str]],
default: tuple[str, str] | None = None,
) -> None:
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
query = session.query(Database).filter(Database.sqlalchemy_uri.like("databend%"))
for database in paginated_update(query):
updated = _rewrite_query_parameters(
database.sqlalchemy_uri, replacements, default
)
if updated:
database.sqlalchemy_uri = updated
session.commit()
def upgrade() -> None:
_migrate(_TO_SSLMODE, default=("sslmode", "disable"))
def downgrade() -> None:
_migrate(_TO_SECURE)
+1 -7
View File
@@ -66,7 +66,6 @@ from typing import Any, NoReturn, TYPE_CHECKING
from flask import current_app as app, g, has_app_context
from flask_babel import gettext as __
from jinja2.exceptions import TemplateError
from superset import db
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
@@ -76,7 +75,6 @@ from superset.exceptions import (
SupersetErrorException,
SupersetParseError,
SupersetSecurityException,
SupersetTemplateException,
SupersetTimeoutException,
)
from superset.extensions import cache_manager
@@ -753,7 +751,6 @@ class SQLExecutor:
:param sql: SQL string potentially containing Jinja2 templates
:param template_params: Parameters to pass to the template
:returns: Rendered SQL string
:raises SupersetTemplateException: if the template fails to render
"""
if template_params is None:
return sql
@@ -761,10 +758,7 @@ class SQLExecutor:
from superset.jinja_context import get_template_processor
tp = get_template_processor(database=self.database)
try:
return tp.process_template(sql, **template_params)
except TemplateError as ex:
raise SupersetTemplateException(str(ex)) from ex
return tp.process_template(sql, **template_params)
def _apply_limit_to_script(self, script: SQLScript, opts: QueryOptions) -> None:
"""
+1 -4
View File
@@ -17,7 +17,7 @@
import builtins
from typing import Callable, Union
from flask import current_app, g, redirect, Response, url_for
from flask import g, redirect, Response, url_for
from flask_appbuilder import expose
from flask_appbuilder.actions import action
from flask_appbuilder.models.sqla.interface import SQLAInterface
@@ -94,9 +94,6 @@ class Dashboard(BaseSupersetView):
viewers=get_default_viewers_for_new_asset(g.user.id if g.user else None),
)
db.session.add(new_dashboard)
if after_create := current_app.config.get("AFTER_ASSET_CREATE"):
db.session.flush()
after_create(new_dashboard, "dashboard")
db.session.commit() # pylint: disable=consider-using-transaction
return redirect(
url_for(
@@ -19,7 +19,6 @@
import re
from random import random
from unittest.mock import MagicMock
from urllib.parse import parse_qs, urlparse
import pytest
@@ -131,27 +130,6 @@ class TestDashboard(SupersetTestCase):
db.session.delete(created_dashboard)
db.session.commit()
def test_new_dashboard_calls_after_asset_create_hook(self):
self.login(ADMIN_USERNAME)
mock_hook = MagicMock()
app = self.app
app.config["AFTER_ASSET_CREATE"] = mock_hook
try:
url = "/dashboard/new/"
self.client.get(url, follow_redirects=False)
mock_hook.assert_called_once()
call_args = mock_hook.call_args
assert isinstance(call_args[0][0], Dashboard)
assert call_args[0][1] == "dashboard"
# Cleanup
created_dashboard = call_args[0][0]
db.session.delete(created_dashboard)
db.session.commit()
finally:
del app.config["AFTER_ASSET_CREATE"]
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@pytest.mark.usefixtures("public_role_like_gamma")
def test_public_user_dashboard_access(self):
+2 -99
View File
@@ -23,7 +23,7 @@ import json # noqa: TID251
import re
from datetime import timedelta
from textwrap import dedent
from typing import Any, cast
from typing import Any
from urllib.parse import parse_qs, urlparse
import pytest
@@ -33,11 +33,7 @@ from sqlalchemy.dialects import sqlite
from sqlalchemy.engine.url import make_url, URL
from sqlalchemy.sql import sqltypes
from superset.db_engine_specs.base import (
BaseEngineSpec,
BasicParametersType,
convert_inspector_columns,
)
from superset.db_engine_specs.base import BaseEngineSpec, convert_inspector_columns
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import OAuth2RedirectError
from superset.sql.parse import Table
@@ -1387,96 +1383,3 @@ def test_base_spec_public_information_includes_supports_offset() -> None:
assert "supports_offset" in info
assert info["supports_offset"] is True
def _parameters(encryption: bool) -> BasicParametersType:
parameters: dict[str, Any] = {
"username": "user",
"password": "pwd",
"host": "localhost",
"port": 5432,
"database": "db",
"query": {},
"encryption": encryption,
}
return cast(BasicParametersType, parameters)
def test_build_sqlalchemy_uri_omits_disable_parameters_by_default() -> None:
"""
Specs that do not define ``encryption_disable_parameters`` must keep
emitting nothing at all when encryption is off.
"""
from superset.db_engine_specs.base import BasicParametersMixin
class TestEngineSpec(BasicParametersMixin):
engine = "testdb"
encryption_parameters = {"sslmode": "require"}
uri = TestEngineSpec.build_sqlalchemy_uri(_parameters(encryption=False))
assert make_url(uri).query == {}
@pytest.mark.parametrize(
"encryption,expected_query",
[
(True, {"sslmode": "require"}),
(False, {"sslmode": "disable"}),
],
)
def test_build_sqlalchemy_uri_applies_disable_parameters(
encryption: bool, expected_query: dict[str, str]
) -> None:
from superset.db_engine_specs.base import BasicParametersMixin
class TestEngineSpec(BasicParametersMixin):
engine = "testdb"
encryption_parameters = {"sslmode": "require"}
encryption_disable_parameters = {"sslmode": "disable"}
uri = TestEngineSpec.build_sqlalchemy_uri(_parameters(encryption=encryption))
assert dict(make_url(uri).query) == expected_query
@pytest.mark.parametrize(
"uri,expected_encryption",
[
("testdb://user:pwd@localhost:5432/db?sslmode=require", True),
("testdb://user:pwd@localhost:5432/db?sslmode=disable", False),
],
)
def test_get_parameters_from_uri_strips_both_parameter_sets(
uri: str, expected_encryption: bool
) -> None:
"""
Both sets share a key with differing values, so neither may leak into
``query`` and reappear as a user-supplied extra parameter.
"""
from superset.db_engine_specs.base import BasicParametersMixin
class TestEngineSpec(BasicParametersMixin):
engine = "testdb"
encryption_parameters = {"sslmode": "require"}
encryption_disable_parameters = {"sslmode": "disable"}
parameters = TestEngineSpec.get_parameters_from_uri(uri)
assert parameters["encryption"] is expected_encryption
assert parameters["query"] == {}
def test_get_parameters_from_uri_keeps_unrelated_query_parameters() -> None:
from superset.db_engine_specs.base import BasicParametersMixin
class TestEngineSpec(BasicParametersMixin):
engine = "testdb"
encryption_parameters = {"sslmode": "require"}
encryption_disable_parameters = {"sslmode": "disable"}
parameters = TestEngineSpec.get_parameters_from_uri(
"testdb://user:pwd@localhost:5432/db?sslmode=disable&application_name=superset"
)
assert parameters["query"] == {"application_name": "superset"}
@@ -16,11 +16,10 @@
# under the License.
from datetime import datetime
from typing import Any, cast, Optional
from typing import Any, Optional
from unittest.mock import Mock
import pytest
from sqlalchemy.engine.url import make_url
from sqlalchemy.types import (
Boolean,
Date,
@@ -33,7 +32,6 @@ from sqlalchemy.types import (
)
from urllib3.connection import HTTPConnection
from superset.db_engine_specs.base import BasicParametersType
from superset.utils.core import GenericDataType
from tests.unit_tests.db_engine_specs.utils import (
assert_column_spec,
@@ -178,131 +176,3 @@ def test_make_label_compatible(column_name: str, expected_result: str) -> None:
label = spec.make_label_compatible(column_name)
assert label == expected_result
def _parameters(**overrides: Any) -> BasicParametersType:
parameters: dict[str, Any] = {
"username": "user",
"password": "pwd",
"host": "localhost",
"port": 443,
"database": "testdb",
"query": {},
"encryption": True,
**overrides,
}
return cast(BasicParametersType, parameters)
@pytest.mark.parametrize(
"encryption,expected_sslmode",
[
(True, "require"),
(False, "disable"),
],
)
def test_build_sqlalchemy_uri_always_states_sslmode(
encryption: bool, expected_sslmode: str
) -> None:
"""
The driver does not infer TLS from the port, so an unencrypted connection
needs ``sslmode=disable`` spelled out rather than the parameter omitted.
"""
from superset.db_engine_specs.databend import DatabendEngineSpec
uri = DatabendEngineSpec.build_sqlalchemy_uri(_parameters(encryption=encryption))
assert make_url(uri).query["sslmode"] == expected_sslmode
def test_build_sqlalchemy_uri_preserves_other_query_params() -> None:
from superset.db_engine_specs.databend import DatabendEngineSpec
uri = DatabendEngineSpec.build_sqlalchemy_uri(
_parameters(query={"warehouse": "wh1"})
)
query = make_url(uri).query
assert query["warehouse"] == "wh1"
assert query["sslmode"] == "require"
def test_build_sqlalchemy_uri_substitutes_default_database() -> None:
from superset.db_engine_specs.databend import DatabendEngineSpec
uri = DatabendEngineSpec.build_sqlalchemy_uri(_parameters(database=""))
assert make_url(uri).database == "__default__"
@pytest.mark.parametrize(
"uri,expected_encryption",
[
("databend://user:pwd@localhost:443/db?sslmode=require", True),
("databend://user:pwd@localhost:8000/db?sslmode=disable", False),
# the driver resolves both spellings to an https scheme
("databend://user:pwd@localhost:443/db?sslmode=enable", True),
("databend://user:pwd@localhost:443/db?sslmode=REQUIRE", True),
# legacy form, stored by Superset before the move to ``sslmode``
("databend://user:pwd@localhost:443/db?secure=true", True),
("databend://user:pwd@localhost:8000/db?secure=false", False),
# databend-py parsed the legacy value as a boolean, so casing is moot
("databend://user:pwd@localhost:443/db?secure=True", True),
# the current spelling wins, and neither may survive into the form
("databend://user:pwd@localhost:443/db?sslmode=require&secure=false", True),
("databend://user:pwd@localhost:8000/db?sslmode=disable&secure=true", False),
("databend://user:pwd@localhost:8000/db", False),
],
)
def test_get_parameters_from_uri_encryption(
uri: str, expected_encryption: bool
) -> None:
from superset.db_engine_specs.databend import DatabendEngineSpec
parameters = DatabendEngineSpec.get_parameters_from_uri(uri)
assert parameters["encryption"] is expected_encryption
assert "sslmode" not in parameters["query"]
assert "secure" not in parameters["query"]
def test_get_parameters_from_uri_accepts_encrypted_extra_keyword() -> None:
"""
``Database.parameters`` passes ``encrypted_extra`` by keyword, and swallows
any exception into an empty dict, so a signature mismatch silently empties
the connection form.
"""
from superset.db_engine_specs.databend import DatabendEngineSpec
parameters = DatabendEngineSpec.get_parameters_from_uri(
"databend://user:pwd@localhost:443/db?sslmode=require",
encrypted_extra={},
)
assert parameters["encryption"] is True
def test_get_parameters_from_uri_restores_empty_database() -> None:
from superset.db_engine_specs.databend import DatabendEngineSpec
parameters = DatabendEngineSpec.get_parameters_from_uri(
"databend://user:pwd@localhost:443/__default__?sslmode=require"
)
assert parameters["database"] == ""
@pytest.mark.parametrize("encryption", [True, False])
def test_parameters_round_trip(encryption: bool) -> None:
from superset.db_engine_specs.databend import DatabendEngineSpec
uri = DatabendEngineSpec.build_sqlalchemy_uri(
_parameters(encryption=encryption, query={"warehouse": "wh1"})
)
parameters = DatabendEngineSpec.get_parameters_from_uri(uri)
assert parameters["encryption"] is encryption
assert parameters["database"] == "testdb"
assert parameters["host"] == "localhost"
assert parameters["port"] == 443
assert parameters["query"] == {"warehouse": "wh1"}
@@ -1092,3 +1092,124 @@ def test_get_oauth2_fresh_token_python(
},
timeout=30.0,
)
def test_has_query_id_before_execute_is_false() -> None:
"""
The cancel id can only be read off the cursor after a statement has been
executed (``cursor.active_command_id`` is unset before that), so the id
must be captured post-execute rather than up front.
"""
assert DatabricksPythonConnectorEngineSpec.has_query_id_before_execute is False
def test_get_cancel_query_id_sea(mocker: MockerFixture) -> None:
"""
On a SEA (Statement Execution API) connection, the cancel id is the
statement id exposed by the driver's ``CommandId``.
"""
from superset.models.sql_lab import Query
cursor = mocker.MagicMock()
cursor.active_command_id.to_sea_statement_id.return_value = "01ecc35f-abcd"
cursor.connection.session.use_sea = True
query = Query()
assert (
DatabricksPythonConnectorEngineSpec.get_cancel_query_id(cursor, query)
== "01ecc35f-abcd"
)
def test_get_cancel_query_id_no_active_command(mocker: MockerFixture) -> None:
"""
Before a statement has been executed on the cursor, there's nothing to
build a cancel id from.
"""
from superset.models.sql_lab import Query
cursor = mocker.MagicMock()
cursor.active_command_id = None
cursor.connection.session.use_sea = True
query = Query()
assert (
DatabricksPythonConnectorEngineSpec.get_cancel_query_id(cursor, query) is None
)
def test_get_cancel_query_id_thrift_backend_returns_none(
mocker: MockerFixture,
) -> None:
"""
When the connection was not established with ``use_sea=True`` (i.e. it's
using the default Thrift backend), no cancel id is captured -- cancelling
a Thrift command requires a secret that's never exposed through any
public driver API, so we deliberately don't attempt it.
"""
from superset.models.sql_lab import Query
cursor = mocker.MagicMock()
cursor.active_command_id.to_sea_statement_id.return_value = "01ecc35f-abcd"
cursor.connection.session.use_sea = False
query = Query()
assert (
DatabricksPythonConnectorEngineSpec.get_cancel_query_id(cursor, query) is None
)
def test_cancel_query_sea_success(mocker: MockerFixture) -> None:
"""
``cancel_query`` sets the reconstructed SEA ``CommandId`` on the fresh
cursor and delegates to the driver's own ``cursor.cancel()``.
"""
from superset.models.sql_lab import Query
command_id = mocker.patch(
"databricks.sql.backend.types.CommandId.from_sea_statement_id"
)
cursor = mocker.MagicMock()
query = Query()
assert (
DatabricksPythonConnectorEngineSpec.cancel_query(cursor, query, "01ecc35f-abcd")
is True
)
command_id.assert_called_once_with("01ecc35f-abcd")
assert cursor.active_command_id == command_id.return_value
cursor.cancel.assert_called_once_with()
def test_cancel_query_invalid_id_returns_false(mocker: MockerFixture) -> None:
"""
A malformed cancel id is rejected before touching the cursor at all.
"""
from superset.models.sql_lab import Query
cursor = mocker.MagicMock()
query = Query()
assert (
DatabricksPythonConnectorEngineSpec.cancel_query(
cursor, query, "'; DROP TABLE foo; --"
)
is False
)
cursor.cancel.assert_not_called()
def test_cancel_query_propagates_errors(mocker: MockerFixture) -> None:
"""
If the cancel attempt itself fails (e.g. the fresh cursor/connection
errors), the error must surface rather than being reported as a failed-
but-handled cancel.
"""
from superset.models.sql_lab import Query
mocker.patch("databricks.sql.backend.types.CommandId.from_sea_statement_id")
cursor = mocker.MagicMock()
cursor.cancel.side_effect = RuntimeError("connection reset")
query = Query()
with pytest.raises(RuntimeError):
DatabricksPythonConnectorEngineSpec.cancel_query(cursor, query, "01ecc35f-abcd")
@@ -26,7 +26,6 @@ here instead of reaching that function.
from __future__ import annotations
import pytest
from freezegun import freeze_time
from superset.commands.chart.exceptions import TimeRangeParseFailError
from superset.mcp_service.common.time_range_validation import (
@@ -130,10 +129,8 @@ class TestValidateTimeRangeSubDayLast:
("Last Hour", "DATEADD(DATETIME('now'), -1, HOUR) : DATETIME('now')"),
],
)
@freeze_time("2026-08-05 12:00:00")
def test_sub_day_last_normalizes(self, value: str, expected: str) -> None:
# Freeze away from midnight so the raw parser's since > today premise
# remains deterministic for every sub-day case.
# Confirm the premise: the raw value really does blow up downstream.
with pytest.raises(ValueError, match="From date cannot be larger"):
get_since_until(time_range=value)
@@ -228,27 +228,6 @@ def test_get_mcp_config_respects_app_config_override() -> None:
assert config["MCP_DISABLED_TOOLS"] == custom
def test_get_mcp_config_includes_mcp_stateless_http_key() -> None:
"""get_mcp_config must include MCP_STATELESS_HTTP in its defaults dict, like
MCP_DEBUG and MCP_RBAC_ENABLED, so an operator override in superset_config.py
is actually read back out via flask_app.config (see run_server() and
__main__.main() in the mcp_service package, which read this key)."""
from superset.mcp_service.mcp_config import get_mcp_config, MCP_STATELESS_HTTP
config = get_mcp_config()
assert "MCP_STATELESS_HTTP" in config
assert config["MCP_STATELESS_HTTP"] is MCP_STATELESS_HTTP is True
def test_get_mcp_config_respects_mcp_stateless_http_override() -> None:
"""An operator's MCP_STATELESS_HTTP=False in superset_config.py must take
precedence over the module-level True default."""
from superset.mcp_service.mcp_config import get_mcp_config
config = get_mcp_config({"MCP_STATELESS_HTTP": False})
assert config["MCP_STATELESS_HTTP"] is False
def test_build_composite_verifier_string_prefix():
"""A plain-string FAB_API_KEY_PREFIXES is wrapped into a single-element list."""
from superset.mcp_service.mcp_config import _build_composite_verifier
@@ -30,12 +30,8 @@ CI.
This module closes that gap with a single smoke test file that:
1. Builds the *real* ASGI app the way ``run_server()`` does --
``mcp.http_app(transport="streamable-http", stateless_http=...)`` --
``mcp.http_app(transport="streamable-http", stateless_http=True)`` --
with the production FastMCP-level middleware list attached.
``stateless_http`` defaults to True (``MCP_STATELESS_HTTP`` in
``mcp_config.py``); this suite pins it to False, the value deployments
override to in order to avoid the crash documented on that config's
docstring.
2. Serves it in-process over real MCP streamable-HTTP JSON-RPC using
``httpx.ASGITransport`` (no real TCP socket, no real network).
3. Drives it with FastMCP's own high-level ``Client``, proving the full
@@ -147,9 +143,7 @@ async def _real_asgi_client() -> AsyncIterator[Client]:
Builds the app the way ``run_server()`` does for the multi-pod/http_app
path (``server.py:938``): FastMCP-level middleware from
``build_middleware_list()`` attached to the shared ``mcp`` instance, then
``mcp.http_app(transport="streamable-http", stateless_http=False)`` --
pinned to False rather than reading ``MCP_STATELESS_HTTP``'s True default,
since False is what deployments actually run (see module docstring).
``mcp.http_app(transport="streamable-http", stateless_http=True)``.
The request/response cycle is driven over ``httpx.ASGITransport`` (no
real socket) using FastMCP's own ``StreamableHttpTransport`` so the
@@ -171,7 +165,7 @@ async def _real_asgi_client() -> AsyncIterator[Client]:
mcp.add_middleware(middleware)
try:
asgi_app = mcp.http_app(transport="streamable-http", stateless_http=False)
asgi_app = mcp.http_app(transport="streamable-http", stateless_http=True)
def httpx_client_factory(**kwargs: Any) -> httpx.AsyncClient:
return httpx.AsyncClient(
@@ -17,10 +17,8 @@
"""Tests for MCP server EventStore creation."""
import contextlib
import os
from collections.abc import Awaitable, Callable, Iterator
from typing import Any, cast
from collections.abc import Awaitable, Callable
from typing import cast
from unittest.mock import MagicMock, patch
import pytest
@@ -318,90 +316,3 @@ def test_create_auth_provider_fails_closed_on_insecure_guest_secret() -> None:
):
with pytest.raises(MCPAuthConfigError):
_create_auth_provider(flask_app)
@contextlib.contextmanager
def _run_server_dependencies(
flask_config: dict[str, Any],
) -> Iterator[MagicMock]:
"""Patch every ``run_server()`` collaborator except stateless_http resolution.
Returns the ``mcp_instance`` mock so callers can assert on the kwargs its
``run()`` was called with -- everything else (auth, middleware, event
store, health endpoint) is stubbed out since this is only exercising the
``flask_app.config.get("MCP_STATELESS_HTTP", ...)`` wiring, not those
other startup steps.
"""
from superset.mcp_service import server
flask_app = MagicMock()
flask_app.config = flask_config
mcp_instance = MagicMock()
with (
patch.object(server, "configure_logging"),
patch.object(server, "_suppress_third_party_warnings"),
patch(
"superset.mcp_service.flask_singleton.get_flask_app",
return_value=flask_app,
),
patch.object(server, "_create_auth_provider", return_value=None),
patch.object(server, "build_middleware_list", return_value=[]),
patch.object(
server, "create_response_size_guard_middleware", return_value=None
),
patch(
"superset.mcp_service.caching.create_response_caching_middleware",
return_value=None,
),
patch.object(server, "init_fastmcp_server", return_value=mcp_instance),
patch.object(server, "_register_health_endpoint"),
patch.object(server, "create_event_store", return_value=None),
patch.object(server, "_build_starlette_middleware", return_value=[]),
):
yield mcp_instance
def test_run_server_defaults_stateless_http_to_true_when_unset() -> None:
"""run_server() must fall back to MCP_STATELESS_HTTP's True default when the
operator's Flask config has no override.
This pins the production wiring added to fix mid-workflow disconnects: if
the ``flask_app.config.get("MCP_STATELESS_HTTP", MCP_STATELESS_HTTP)`` call
in ``run_server()`` were reverted to a hardcoded ``True``, or the default
were flipped, this test would still pass -- so it's the ``is True`` on the
*resolved* value, not just the module constant, that catches a broken
resolution.
"""
from superset.mcp_service.server import run_server
port = 59901
os.environ.pop(f"FASTMCP_RUNNING_{port}", None)
try:
with _run_server_dependencies(flask_config={}) as mcp_instance:
run_server(host="127.0.0.1", port=port)
mcp_instance.run.assert_called_once()
assert mcp_instance.run.call_args.kwargs["stateless_http"] is True
finally:
os.environ.pop(f"FASTMCP_RUNNING_{port}", None)
def test_run_server_respects_mcp_stateless_http_false_override() -> None:
"""An operator's MCP_STATELESS_HTTP=False (the value deployments actually run,
per the docstring in mcp_config.py) must reach ``mcp_instance.run()`` rather
than the module's True default."""
from superset.mcp_service.server import run_server
port = 59902
os.environ.pop(f"FASTMCP_RUNNING_{port}", None)
try:
with _run_server_dependencies(
flask_config={"MCP_STATELESS_HTTP": False}
) as mcp_instance:
run_server(host="127.0.0.1", port=port)
mcp_instance.run.assert_called_once()
assert mcp_instance.run.call_args.kwargs["stateless_http"] is False
finally:
os.environ.pop(f"FASTMCP_RUNNING_{port}", None)
@@ -1,253 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Tests for migration ``c4a1b8e2d739_databend_secure_to_sslmode``.
Covers the query-parameter rewrite helper, the full upgrade() path over a
mixture of Databend and non-Databend connections, and the downgrade()
round trip.
"""
from __future__ import annotations
from importlib import import_module
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
migration = import_module(
"superset.migrations.versions."
"2026-08-06_00-00_c4a1b8e2d739_databend_secure_to_sslmode"
)
Database = migration.Database
_rewrite_query_parameters = migration._rewrite_query_parameters
_TO_SSLMODE = migration._TO_SSLMODE
_TO_SECURE = migration._TO_SECURE
_DEFAULT = ("sslmode", "disable")
# Superset stores the password as this mask rather than the real credential
MASK = "X" * 10
@pytest.fixture
def engine():
engine = create_engine("sqlite:///:memory:", future=True)
migration.Base.metadata.create_all(engine)
return engine
def _run(migrate, conn) -> None:
session = Session(bind=conn, future=True)
with (
patch.object(migration, "op") as mock_op,
patch.object(migration, "db") as mock_db,
):
mock_op.get_bind.return_value = conn
mock_db.Session.return_value = session
migrate()
@pytest.mark.parametrize(
"uri,expected",
[
(
f"databend://user:{MASK}@host:8000/db?secure=false",
f"databend://user:{MASK}@host:8000/db?sslmode=disable",
),
(
f"databend://user:{MASK}@host:443/db?secure=true",
f"databend://user:{MASK}@host:443/db?sslmode=require",
),
# databend-py parsed the value as a boolean, so casing carried no meaning
(
f"databend://user:{MASK}@host:443/db?secure=True",
f"databend://user:{MASK}@host:443/db?sslmode=require",
),
# unrelated parameters keep their position and encoding
(
f"databend://user:{MASK}@host:8000/db?warehouse=wh&secure=false&presign=on",
f"databend://user:{MASK}@host:8000/db?warehouse=wh&sslmode=disable&presign=on",
),
],
)
def test_rewrite_query_parameters_replaces_secure(uri: str, expected: str) -> None:
assert _rewrite_query_parameters(uri, _TO_SSLMODE, _DEFAULT) == expected
@pytest.mark.parametrize(
"uri",
[
# already migrated
f"databend://user:{MASK}@host:8000/db?sslmode=disable",
f"databend://user:{MASK}@host:443/db?sslmode=require",
# an unrecognised value is left for a human rather than guessed at
f"databend://user:{MASK}@host:8000/db?secure=maybe",
],
)
def test_rewrite_query_parameters_leaves_migrated_uris_alone(uri: str) -> None:
assert _rewrite_query_parameters(uri, _TO_SSLMODE, _DEFAULT) is None
@pytest.mark.parametrize(
"uri,expected",
[
(
f"databend://user:{MASK}@host:8000/db",
f"databend://user:{MASK}@host:8000/db?sslmode=disable",
),
(
f"databend://user:{MASK}@host:8000/db?warehouse=wh",
f"databend://user:{MASK}@host:8000/db?warehouse=wh&sslmode=disable",
),
],
)
def test_rewrite_query_parameters_pins_plaintext_default(
uri: str, expected: str
) -> None:
"""
A URI with no TLS parameter was plaintext under databend-py's http default,
so it needs sslmode=disable to keep behaving that way under the Rust core.
"""
assert _rewrite_query_parameters(uri, _TO_SSLMODE, _DEFAULT) == expected
def test_rewrite_query_parameters_ignores_question_mark_in_credentials() -> None:
"""
The credentials are not escaped for ``?``, so the query delimiter has to be
located after them or the parameters are never found.
"""
rewritten = _rewrite_query_parameters(
"databend://user:pa?ss@host:8000/db?secure=false", _TO_SSLMODE, _DEFAULT
)
assert rewritten == "databend://user:pa?ss@host:8000/db?sslmode=disable"
def test_upgrade_rewrites_only_databend_connections(engine) -> None:
with Session(engine, future=True) as seed:
seed.add_all(
[
Database(
id=1,
sqlalchemy_uri=f"databend://user:{MASK}@host:8000/db?secure=false",
),
Database(
id=2,
sqlalchemy_uri=f"databend://user:{MASK}@host:443/db?secure=true",
),
# no TLS parameter: plaintext under the old client, so it must
# be pinned rather than left to the new https default
Database(id=3, sqlalchemy_uri=f"databend://user:{MASK}@host:8000/db"),
# a different engine that happens to use the same parameter
Database(
id=4,
sqlalchemy_uri=f"clickhousedb://user:{MASK}@host:8443/db?secure=true",
),
]
)
seed.commit()
with engine.begin() as conn:
_run(migration.upgrade, conn)
with Session(engine, future=True) as verify:
assert (
verify.get(Database, 1).sqlalchemy_uri
== f"databend://user:{MASK}@host:8000/db?sslmode=disable"
)
assert (
verify.get(Database, 2).sqlalchemy_uri
== f"databend://user:{MASK}@host:443/db?sslmode=require"
)
assert (
verify.get(Database, 3).sqlalchemy_uri
== f"databend://user:{MASK}@host:8000/db?sslmode=disable"
)
assert (
verify.get(Database, 4).sqlalchemy_uri
== f"clickhousedb://user:{MASK}@host:8443/db?secure=true"
)
def test_upgrade_is_idempotent(engine) -> None:
with Session(engine, future=True) as seed:
seed.add(
Database(
id=1, sqlalchemy_uri=f"databend://user:{MASK}@host:8000/db?secure=false"
)
)
seed.commit()
for _ in range(2):
with engine.begin() as conn:
_run(migration.upgrade, conn)
with Session(engine, future=True) as verify:
assert (
verify.get(Database, 1).sqlalchemy_uri
== f"databend://user:{MASK}@host:8000/db?sslmode=disable"
)
@pytest.mark.parametrize(
"uri,expected",
[
(
f"databend://user:{MASK}@host:8000/db?sslmode=disable",
f"databend://user:{MASK}@host:8000/db?secure=false",
),
(
f"databend://user:{MASK}@host:443/db?sslmode=require",
f"databend://user:{MASK}@host:443/db?secure=true",
),
# the driver treats enable as an alias of require
(
f"databend://user:{MASK}@host:443/db?sslmode=enable",
f"databend://user:{MASK}@host:443/db?secure=true",
),
],
)
def test_downgrade_restores_secure(uri: str, expected: str) -> None:
assert _rewrite_query_parameters(uri, _TO_SECURE) == expected
def test_downgrade_does_not_add_a_parameter(engine) -> None:
"""
Only upgrade pins a default; downgrade must leave a bare URI bare rather
than inventing a secure parameter Superset never wrote.
"""
assert (
_rewrite_query_parameters(f"databend://user:{MASK}@host:8000/db", _TO_SECURE)
is None
)
def test_round_trip_through_upgrade_and_downgrade(engine) -> None:
original = f"databend://user:{MASK}@host:8000/db?secure=false"
with Session(engine, future=True) as seed:
seed.add(Database(id=1, sqlalchemy_uri=original))
seed.commit()
with engine.begin() as conn:
_run(migration.upgrade, conn)
with engine.begin() as conn:
_run(migration.downgrade, conn)
with Session(engine, future=True) as verify:
assert verify.get(Database, 1).sqlalchemy_uri == original
@@ -44,7 +44,6 @@ from superset_core.queries.types import (
)
from superset.models.core import Database
from tests.unit_tests.conftest import with_feature_flags
# Note: database, database_with_dml, mock_db_session fixtures and
# mock_query_execution helper are imported from conftest.py
@@ -790,42 +789,6 @@ def test_execute_async_dml_without_permission_raises(
database.execute_async("INSERT INTO users (name) VALUES ('test')")
@with_feature_flags(ENABLE_TEMPLATE_PROCESSING=True)
def test_execute_async_undefined_template_var_raises_superset_template_exception(
mocker: MockerFixture, database: Database, app_context: None
) -> None:
"""A Jinja template referencing an undefined variable (not called as a
function) must not leak a raw ``jinja2.exceptions.UndefinedError`` out of
``execute_async`` - it should surface as ``SupersetTemplateException``."""
from superset.exceptions import SupersetTemplateException
mocker.patch.dict(
current_app.config, {"SQL_QUERY_MUTATOR": None, "SQLLAB_TIMEOUT": 30}
)
options = QueryOptions(template_params={"foo": "bar"})
with pytest.raises(SupersetTemplateException):
database.execute_async("SELECT {{ missing_var[0] }}", options=options)
@with_feature_flags(ENABLE_TEMPLATE_PROCESSING=True)
def test_execute_sync_undefined_template_var_returns_failed_result(
mocker: MockerFixture, database: Database, app_context: None
) -> None:
"""The sync ``execute`` path's broad ``except Exception`` still catches the
template rendering failure and returns a FAILED ``QueryResult``, unchanged
by the ``_render_sql_template`` fix."""
mocker.patch.dict(
current_app.config, {"SQL_QUERY_MUTATOR": None, "SQLLAB_TIMEOUT": 30}
)
options = QueryOptions(template_params={"foo": "bar"})
result = database.execute("SELECT {{ missing_var[0] }}", options=options)
assert result.status == QueryStatus.FAILED
def test_async_handle_get_status(
mocker: MockerFixture,
database: Database,
@@ -117,20 +117,11 @@ def test_clock_uses_now_not_utcnow() -> None:
purge.assert_called_once_with(Slice, now - timedelta(days=30), False)
def test_default_config_purges_for_real_after_the_retention_window() -> None:
"""The shipped defaults make the docs' retention promise true.
Superseding ``test_default_config_is_safe``: dry-run was the
introducing release's posture, deliberately opt-in so operators could
validate ``would_purge`` counts against production first. Purging is
live by default, and ``SOFT_DELETE_PURGE_DRY_RUN`` is retained as the
operational lever to put it back. Pinned so a default change is a
deliberate edit here rather than a silent one.
"""
def test_default_config_is_safe() -> None:
from superset import config
assert config.SOFT_DELETE_RETENTION_DAYS == 30
assert config.SOFT_DELETE_PURGE_DRY_RUN is False
assert config.SOFT_DELETE_PURGE_DRY_RUN is True
def test_default_celery_config_registers_daily_purge() -> None: