Compare commits

...
Author SHA1 Message Date
Elizabeth Thompson 4a24fb5a0e fix(sql-lab): raise SupersetTemplateException instead of raw UndefinedError in SQLExecutor.execute_async
_render_sql_template() called process_template() with no try/except, so a
Jinja UndefinedError for an undefined variable not called as a function
would leak raw past execute_async() (execute() already caught it via its
broad except Exception). Wrap the call and re-raise as
SupersetTemplateException, consistent with how process_template() itself
already handles this failure mode elsewhere.
2026-08-06 16:47:15 +00:00
ba09f399ac feat(soft-delete): enable soft delete by default and purge for real (#42800)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 09:34:55 -07:00
Michael S. MolinaandClaude Sonnet 5 e4c306f5eb fix(deps): revert simple-zstd from 2.1.0 back to 1.4.2, again (#42804)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 13:18:07 -03:00
Mike BridgeandMike Bridge ea8fa58158 docs: scope AI pre-commit checks to changed files, to align with ci's pre-commit checks (#42835)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-08-06 10:59:33 -03:00
Mike BridgeandMike Bridge a00e2952a1 style(frontend): apply oxfmt to existing files (#42813)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-08-06 10:58:27 -03:00
Pat BuxtonandClaude Opus 5 de93a19b3c fix(engine): update databend engine spec for dialect version >=0.4.6 (#28627)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 06:28:16 -07:00
Alexandru Soare 57d6e5c637 feat(hooks): add configurable post-creation hook for dashboards (#42837) 2026-08-06 16:11:55 +03:00
dependabot[bot] 0fc0d21dd3 chore(deps): bump github/codeql-action/init from 4.37.3 to 4.37.4 (#42827)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-06 14:12:22 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 76e6909cfd chore(deps): bump @swc/core from 1.15.46 to 1.15.47 in /docs (#42825)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 03:14:34 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 450e43b8f2 chore(deps): bump baseline-browser-mapping from 2.11.6 to 2.11.7 in /docs (#42826)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 03:14:31 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> a7dd86adc6 chore(deps): bump github/codeql-action/analyze from 4.37.3 to 4.37.4 (#42828)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 03:14:25 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> bbd7ac7278 chore(deps-dev): bump baseline-browser-mapping from 2.11.6 to 2.11.7 in /superset-frontend (#42830)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 03:14:22 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 05af8eeff2 chore(deps): bump react-map-gl from 8.1.1 to 8.1.2 in /superset-frontend (#42831)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 03:14:18 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 3f413e6e10 chore(deps-dev): bump @swc/core from 1.15.46 to 1.15.47 in /superset-frontend (#42832)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 03:14:14 -07:00
jesperctandJoe Li 4278f4b3d9 fix(explore): honor explicit List legend selection instead of flipping to scroll (#41749)
Co-authored-by: Joe Li <joe@preset.io>
2026-08-05 23:08:05 -07:00
Joe Li b8c44a1ad5 fix(ci): restore master validation checks (#42807) 2026-08-05 22:58:05 -07:00
JUST.in DO ITandClaude Sonnet 5 d594a4d157 fix(mcp): make streamable-http session mode configurable via MCP_STATELESS_HTTP (#42814)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 21:31:22 -07:00
Evan RusackasandClaude Code 442995481c test(deckgl): Polygon chart with a column literally named "polygon" (#33669) (#42591)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-05 20:27:13 -07:00
64 changed files with 2008 additions and 773 deletions
+7
View File
@@ -47,6 +47,13 @@ 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@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
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@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
category: "/language:${{matrix.language}}"
+11 -4
View File
@@ -2,22 +2,29 @@
Apache Superset is a data visualization platform with Flask/Python backend and React/TypeScript frontend.
## ⚠️ CRITICAL: Always Run Pre-commit Before Pushing
## Run Pre-commit Before Pushing
**ALWAYS run `pre-commit run --all-files` before pushing commits.** CI will fail if pre-commit checks don't pass. This is non-negotiable.
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.
```bash
# Stage your changes first
git add .
# Run pre-commit on all files
pre-commit run --all-files
# Run pre-commit on staged files
pre-commit run
# 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
+29 -1
View File
@@ -24,6 +24,34 @@ 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
@@ -454,7 +482,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.
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.
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.
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.46",
"@swc/core": "^1.15.47",
"antd": "^6.5.2",
"baseline-browser-mapping": "^2.11.6",
"baseline-browser-mapping": "^2.11.7",
"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": false,
"default": true,
"lifecycle": "development",
"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."
"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."
},
{
"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.2.3`.
Superset has been tested on `databend-sqlalchemy>=0.5.5`.
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?secure=false
databend://user:password@localhost:8000/default?sslmode=disable
```
#### Databricks
+68 -68
View File
@@ -4787,86 +4787,86 @@
dependencies:
apg-lite "^1.0.4"
"@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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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@^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==
"@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==
dependencies:
"@swc/counter" "^0.1.3"
"@swc/types" "^0.1.27"
optionalDependencies:
"@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/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/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.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==
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==
batch@0.6.1:
version "0.6.1"
+230 -150
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": "^2.1.0",
"simple-zstd": "^1.4.2",
"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.46",
"@swc/core": "^1.15.47",
"@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.6",
"baseline-browser-mapping": "^2.11.7",
"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.46",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.46.tgz",
"integrity": "sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.47.tgz",
"integrity": "sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
@@ -11497,18 +11497,18 @@
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@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/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"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
@@ -11520,9 +11520,9 @@
}
},
"node_modules/@swc/core-darwin-arm64": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -11536,9 +11536,9 @@
}
},
"node_modules/@swc/core-darwin-x64": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -11552,9 +11552,9 @@
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"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==",
"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==",
"cpu": [
"arm"
],
@@ -11568,9 +11568,9 @@
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -11587,9 +11587,9 @@
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -11606,9 +11606,9 @@
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"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==",
"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==",
"cpu": [
"ppc64"
],
@@ -11625,9 +11625,9 @@
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"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==",
"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==",
"cpu": [
"s390x"
],
@@ -11644,9 +11644,9 @@
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -11663,9 +11663,9 @@
}
},
"node_modules/@swc/core-linux-x64-musl": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -11682,9 +11682,9 @@
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -11698,9 +11698,9 @@
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"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==",
"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==",
"cpu": [
"ia32"
],
@@ -11714,9 +11714,9 @@
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -13728,6 +13728,72 @@
"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",
@@ -15612,9 +15678,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"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==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.7.tgz",
"integrity": "sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -19252,6 +19318,12 @@
"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",
@@ -19297,6 +19369,54 @@
"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",
@@ -19448,7 +19568,6 @@
"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"
@@ -32332,7 +32451,6 @@
"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"
@@ -33363,6 +33481,17 @@
"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",
@@ -33915,6 +34044,19 @@
"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",
@@ -35741,13 +35883,13 @@
}
},
"node_modules/react-map-gl": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-8.1.1.tgz",
"integrity": "sha512-aSqFAFoxvY7wxbGI93Dz0E41171mkAb3GcNbnkFIotmu88OFw495os6mIDZSi7irYNT/PZEIOEHUxhun4ToGuQ==",
"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==",
"license": "MIT",
"dependencies": {
"@vis.gl/react-mapbox": "8.1.1",
"@vis.gl/react-maplibre": "8.1.1"
"@vis.gl/react-mapbox": "8.1.2",
"@vis.gl/react-maplibre": "8.1.2"
},
"peerDependencies": {
"mapbox-gl": ">=1.13.0",
@@ -35764,72 +35906,6 @@
}
}
},
"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",
@@ -37965,17 +38041,24 @@
"license": "MIT"
},
"node_modules/simple-zstd": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/simple-zstd/-/simple-zstd-2.1.0.tgz",
"integrity": "sha512-pYzmKWl167db0EHoczlsSpmyjvZ7OinXciHicDEtlHjSKZlo1hPz6tXSyOfS84QIrbWPYT0XW9tx24YtGdQ6cA==",
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/simple-zstd/-/simple-zstd-1.4.2.tgz",
"integrity": "sha512-kGYEvT33M5XfyQvvW4wxl3eKcWbdbCc1V7OZzuElnaXft0qbVzoIIXHXiCm3JCUki+MZKKmvjl8p2VGLJc5Y/A==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4",
"is-zst": "^1.0.0",
"tmp-promise": "^3.0.3"
},
"engines": {
"node": ">=22.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"
}
},
"node_modules/sirv": {
@@ -38939,6 +39022,12 @@
"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",
@@ -40363,20 +40452,12 @@
"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",
@@ -43033,7 +43114,6 @@
"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": {
@@ -44694,7 +44774,7 @@
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.27.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.1",
"react-map-gl": "^8.1.2",
"supercluster": "^8.0.1"
},
"peerDependencies": {
@@ -44801,7 +44881,7 @@
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.4",
"prop-types": "^15.8.1",
"react-map-gl": "^8.1.1",
"react-map-gl": "^8.1.2",
"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": "^2.1.0",
"simple-zstd": "^1.4.2",
"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.46",
"@swc/core": "^1.15.47",
"@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.6",
"baseline-browser-mapping": "^2.11.7",
"cheerio": "1.2.0",
"concurrently": "^10.0.4",
"copy-webpack-plugin": "^14.0.0",
@@ -34,8 +34,9 @@ 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: '';
@@ -48,7 +49,8 @@ export const DropdownButton = ({
.anticon {
vertical-align: middle;
}
}`}
}`
}
`;
const button = (
<Dropdown.Button
@@ -58,13 +60,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,18 +221,22 @@ 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,9 +171,11 @@ 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,14 +73,16 @@ 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};
@@ -168,40 +170,46 @@ 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%;
}
}
`}
${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)`};
.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)`
};
}
}
}
`
}
`;
}}
`;
@@ -51,8 +51,10 @@ 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 {
@@ -66,9 +68,11 @@ 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,10 +19,16 @@
import { expect } from '@playwright/test';
import { Modal, Input } from '../core';
import { isFeatureEnabled } from '../../helpers/featureFlags';
/**
* Delete confirmation modal that requires typing "DELETE" to confirm.
* Used throughout Superset for destructive delete operations.
* 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.
*
* Provides primitives for tests to compose deletion flows.
*/
@@ -96,4 +102,40 @@ 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,11 +76,9 @@ test('should delete a chart with confirmation', async ({
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Confirm: types "DELETE" while the modal is destructive, and goes straight
// through once SOFT_DELETE makes it a recoverable archive instead.
await deleteModal.confirmDeletion();
// Modal should close
await deleteModal.waitForHidden();
@@ -237,11 +235,9 @@ test('should bulk delete multiple charts', async ({
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Confirm: types "DELETE" while the modal is destructive, and goes straight
// through once SOFT_DELETE makes it a recoverable archive instead.
await deleteModal.confirmDeletion();
// Modal should close
await deleteModal.waitForHidden();
@@ -81,11 +81,10 @@ test('should delete a dashboard with confirmation', async ({
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button (waits for it to become enabled)
await deleteModal.clickDelete();
// 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();
// Modal should close
await deleteModal.waitForHidden();
@@ -191,11 +190,9 @@ test('should bulk delete multiple dashboards', async ({
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Confirm: types "DELETE" while the modal is destructive, and goes straight
// through once SOFT_DELETE makes it a recoverable archive instead.
await deleteModal.confirmDeletion();
// Modal should close
await deleteModal.waitForHidden();
@@ -37,6 +37,7 @@ import {
ENDPOINTS,
} from '../../helpers/api/dataset';
import { createTestDataset } from './dataset-test-helpers';
import { isFeatureEnabled } from '../../helpers/featureFlags';
import {
waitForGet,
waitForPost,
@@ -120,19 +121,21 @@ test('should delete a dataset with confirmation', async ({
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Confirm: types "DELETE" while the modal is destructive, and goes straight
// through once SOFT_DELETE makes it a recoverable archive instead.
await deleteModal.confirmDeletion();
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears with correct message.
// 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.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
await expect(toast.getMessage()).toContainText('Deleted');
await expect(toast.getMessage()).toContainText(
(await isFeatureEnabled(page, 'SOFT_DELETE')) ? 'Archived' : '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, {
@@ -431,11 +434,9 @@ test('should bulk delete multiple datasets', async ({
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Confirm: types "DELETE" while the modal is destructive, and goes straight
// through once SOFT_DELETE makes it a recoverable archive instead.
await deleteModal.confirmDeletion();
// 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 { APIRequestContext, Page } from '@playwright/test';
import { Page } from '@playwright/test';
import rison from 'rison';
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiGet } from '../../helpers/api/requests';
@@ -55,17 +55,15 @@ const OPAQUE_ID =
* `can_overwrite` gate derived from them) live in the Subject id space, not
* the user id space.
*/
async function currentUserSubjectId(
request: APIRequestContext,
): Promise<number> {
const meRes = await request.get('/api/v1/me/');
async function currentUserSubjectId(page: Page): Promise<number> {
const meRes = await apiGet(page, '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 request.get(`/api/v1/security/subject/?q=${q}`);
const res = await apiGet(page, `api/v1/security/subject/?q=${q}`);
expect(res.ok(), 'subject lookup request').toBeTruthy();
const subjects = (await res.json()).result;
expect(
@@ -141,7 +139,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.request);
const adminSubjectId = await currentUserSubjectId(page);
await apiPutChart(page, chartId, {
editors: [adminSubjectId],
slice_name: `${baseName} ·vh1`,
@@ -60,11 +60,9 @@ const DEFAULT_LEGEND_ICON_WIDTH = 25;
const LEGEND_ICON_LABEL_GAP = 5;
const LEGEND_HORIZONTAL_SIDE_GUTTER = 16;
const LEGEND_HORIZONTAL_ROW_HEIGHT = 24;
const LEGEND_HORIZONTAL_MAX_ROWS = 2;
const LEGEND_HORIZONTAL_MAX_HEIGHT_RATIO = 0.25;
// Cap the reserved horizontal legend margin so an overflowing legend can't eat the plot.
const MAX_LEGEND_MARGIN_RATIO = 0.4;
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
@@ -84,10 +82,6 @@ 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);
@@ -269,37 +263,30 @@ function getHorizontalPlainLegendLayout({
showSelectors,
theme,
);
const rowsForMargin = Number.isFinite(rowCount)
? rowCount
: legendLabels.length;
const requiredMargin =
defaultLegendPadding[orientation] +
Math.max(0, rowCount - 1) * LEGEND_HORIZONTAL_ROW_HEIGHT;
const maxLegendHeight =
Math.max(0, rowsForMargin - 1) * LEGEND_HORIZONTAL_ROW_HEIGHT;
const boundedMargin =
availableHeight > 0
? availableHeight * LEGEND_HORIZONTAL_MAX_HEIGHT_RATIO
: Infinity;
if (
!Number.isFinite(rowCount) ||
rowCount > LEGEND_HORIZONTAL_MAX_ROWS ||
requiredMargin > maxLegendHeight
) {
return SCROLL_LEGEND_LAYOUT;
}
? Math.min(requiredMargin, availableHeight * MAX_LEGEND_MARGIN_RATIO)
: requiredMargin;
return {
effectiveMargin: Math.max(currentMargin, requiredMargin),
effectiveMargin: Math.max(currentMargin, boundedMargin),
effectiveType: LegendType.Plain,
};
}
function getVerticalPlainLegendLayout({
availableHeight,
availableWidth,
currentMargin,
legendLabels,
showSelectors,
theme,
}: {
availableHeight: number;
availableWidth: number;
currentMargin: number;
legendLabels: string[];
@@ -313,17 +300,6 @@ 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;
@@ -333,21 +309,13 @@ function getVerticalPlainLegendLayout({
requiredSelectorMargin,
),
);
const maxLegendWidth =
const boundedMargin =
availableWidth > 0
? availableWidth * LEGEND_VERTICAL_MAX_WIDTH_RATIO
: Infinity;
if (
rowsPerColumn <= 0 ||
legendLabels.length > rowsPerColumn ||
requiredMargin > maxLegendWidth
) {
return SCROLL_LEGEND_LAYOUT;
}
? Math.min(requiredMargin, availableWidth * MAX_LEGEND_MARGIN_RATIO)
: requiredMargin;
return {
effectiveMargin: Math.max(currentMargin, requiredMargin),
effectiveMargin: Math.max(currentMargin, boundedMargin),
effectiveType: LegendType.Plain,
};
}
@@ -404,7 +372,6 @@ export function getLegendLayoutResult({
}
return getVerticalPlainLegendLayout({
availableHeight: resolvedAvailableHeight,
availableWidth: resolvedAvailableWidth,
currentMargin: resolvedLegendMargin,
legendLabels,
@@ -18,6 +18,7 @@
*/
import { AxisType, ChartProps } from '@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
import type { LegendComponentOption } from 'echarts/components';
import {
LegendOrientation,
LegendType,
@@ -304,7 +305,7 @@ describe('legend sorting', () => {
expect(legendData).toEqual(['series value 2', 'series value 1']);
});
test('falls back to scroll for plain legends with an overlong legend item', () => {
test('honors an explicit List selection for plain legends with an overlong legend item', () => {
const props = new ChartProps({
...chartPropsConfig,
width: 320,
@@ -321,7 +322,7 @@ describe('legend sorting', () => {
'Y Axis': 'first',
tooltip_column: 'tooltip value 1',
series:
'This is a ridiculously long legend label that should switch to scroll',
'This is a ridiculously long legend label that stays a plain List',
},
{
startTime: Date.UTC(2025, 1, 1, 18, 0, 0),
@@ -344,7 +345,9 @@ describe('legend sorting', () => {
const result = transformProps(props as EchartsGanttChartProps);
expect((result.echartOptions.legend as any).type).toBe(LegendType.Scroll);
expect((result.echartOptions.legend as LegendComponentOption).type).toBe(
LegendType.Plain,
);
});
test('keeps legend visibility driven by showLegend for single-series charts', () => {
@@ -141,7 +141,7 @@ describe('Pie transformProps', () => {
]);
});
test('falls back to scroll for plain legends with overlong labels', () => {
test('honors an explicit List selection 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.Scroll,
LegendType.Plain,
);
});
});
@@ -852,7 +852,7 @@ describe('Bar Chart X-axis Time Formatting', () => {
type: LegendType.Plain,
});
test('should fall back to scroll for horizontal bottom legends after margin expansion reduces available width', () => {
test('honors an explicit List selection for horizontal bottom legends and reserves margin', () => {
const legendLabels = [
'This is a long sales legend',
'This is a long marketing legend',
@@ -899,48 +899,9 @@ describe('Bar Chart X-axis Time Formatting', () => {
legendType: LegendType.Plain,
showLegend: true,
};
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;
// 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 chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
@@ -948,7 +909,7 @@ describe('Bar Chart X-axis Time Formatting', () => {
defaultFormData: regressionFormData,
defaultVizType: 'echarts_timeseries_bar',
defaultQueriesData: longLegendData,
width: resolvedChartWidth,
width: chartWidth,
height: baseChartPropsConfig.height,
});
@@ -956,7 +917,12 @@ describe('Bar Chart X-axis Time Formatting', () => {
const legend = transformedProps.echartOptions
.legend as LegendComponentOption;
const grid = transformedProps.echartOptions.grid as GridComponentOption;
const expectedPadding = getPadding(
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(
true,
LegendOrientation.Bottom,
false,
@@ -968,30 +934,41 @@ describe('Bar Chart X-axis Time Formatting', () => {
undefined,
true,
);
[expectedPadding.bottom, expectedPadding.left] = [
expectedPadding.left,
expectedPadding.bottom,
[basePadding.bottom, basePadding.left] = [
basePadding.left,
basePadding.bottom,
];
const expandedPadding = getPadding(
// 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(
true,
LegendOrientation.Bottom,
false,
false,
expandedLegendMargin,
layout.effectiveMargin,
false,
undefined,
undefined,
undefined,
true,
);
[expandedPadding.bottom, expandedPadding.left] = [
expandedPadding.left,
expandedPadding.bottom,
[reservedPadding.bottom, reservedPadding.left] = [
reservedPadding.left,
reservedPadding.bottom,
];
expect(legend.type).toBe(LegendType.Scroll);
expect(grid.bottom).toBe(expectedPadding.bottom);
expect(grid.bottom).not.toBe(expandedPadding.bottom);
expect(grid.bottom).toBe(reservedPadding.bottom);
expect(grid.bottom as number).toBeGreaterThan(
basePadding.bottom as number,
);
});
});
});
@@ -916,40 +916,38 @@ describe('legend sorting', () => {
'Boston',
]);
});
});
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 },
),
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 },
),
];
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 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.Plain);
});
test('honors user-selected plain legend type for top orientation when space allows (#39540)', () => {
@@ -1018,9 +1018,8 @@ test('getLegendLayoutResult honors user-selected plain type for many horizontal
});
expect(layout.effectiveType).toBe(LegendType.Plain);
expect(layout.effectiveMargin).toBeGreaterThanOrEqual(
defaultLegendPadding[LegendOrientation.Top],
);
// One row of items fits, so only the base top padding is reserved.
expect(layout.effectiveMargin).toBe(20);
});
test('getLegendLayoutResult keeps user-selected plain type for bottom-oriented legends when space allows', () => {
@@ -1082,47 +1081,85 @@ test('getLegendLayoutResult adds extra margin for wrapped plain horizontal legen
);
});
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,
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,
});
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 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,
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,
});
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 falls back to scroll when reserved horizontal width reduces plain legend capacity', () => {
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', () => {
const availableWidth = getHorizontalLegendAvailableWidth({
chartWidth: 265,
orientation: LegendOrientation.Top,
@@ -1130,38 +1167,38 @@ test('getLegendLayoutResult falls back to scroll when reserved horizontal width
zoomable: true,
});
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,
const layout = getLegendLayoutResult({
availableWidth,
chartHeight: 400,
chartWidth: 265,
legendItems: ['Alpha', 'Beta', 'Gamma'],
legendMargin: null,
orientation: LegendOrientation.Top,
show: true,
theme,
type: LegendType.Plain,
});
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 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,
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,
});
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', () => {
@@ -1202,56 +1239,87 @@ test('getLegendLayoutResult adds extra margin for wide vertical plain legends',
);
});
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,
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,
});
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 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,
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,
});
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', () => {
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,
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(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.1",
"react-map-gl": "^8.1.2",
"supercluster": "^8.0.1"
},
"peerDependencies": {
@@ -1150,13 +1150,15 @@ 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`
@@ -1164,10 +1166,11 @@ 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,
@@ -1186,15 +1189,18 @@ 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;
`;
@@ -1203,10 +1209,12 @@ 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.1",
"react-map-gl": "^8.1.2",
"tinycolor2": "^1.6.0",
"urijs": "^1.19.11",
"xss": "^1.0.15"
@@ -529,4 +529,66 @@ 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,17 +188,21 @@ 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'};
${
isOver
? isForbidden
? theme.colorError
: theme.colorPrimary
: 'transparent'
};
border-radius: ${theme.borderRadius}px;
background: ${isOver
? isForbidden
? theme.colorErrorBg
: theme.colorPrimaryBg
: 'transparent'};
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,12 +144,14 @@ 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,10 +30,12 @@ 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,32 +46,36 @@ 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`
@@ -88,11 +92,13 @@ 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,14 +63,16 @@ 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
@@ -89,21 +91,23 @@ 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,13 +67,15 @@ 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,9 +63,11 @@ 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,9 +64,11 @@ 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,10 +1998,12 @@ 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,9 +86,11 @@ 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,30 +97,34 @@ 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,10 +104,12 @@ 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};
`
}
`}
`;
+5 -13
View File
@@ -17,7 +17,7 @@
* under the License.
*/
const zlib = require('zlib');
const { decompress: zstdDecompress } = require('simple-zstd');
const { 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.
*/
async function processHTML(proxyResponse, response) {
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 @@ async function processHTML(proxyResponse, response) {
} else if (responseEncoding === 'deflate') {
uncompress = zlib.createInflate();
} else if (responseEncoding === 'zstd') {
uncompress = await zstdDecompress();
uncompress = ZSTDDecompress();
}
if (uncompress) {
originalResponse.pipe(uncompress);
@@ -178,15 +178,7 @@ 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).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}`,
);
}
});
processHTML(proxyResponse, response);
} else {
const isCSV = (proxyResponse.headers['content-type'] || '').includes(
'text/csv',
+11 -9
View File
@@ -689,13 +689,14 @@ 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 (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.
# 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.
# @lifecycle: development
"SOFT_DELETE": False,
"SOFT_DELETE": True,
# Enable semantic layers and show semantic views alongside datasets
# @lifecycle: development
"SEMANTIC_LAYERS": False,
@@ -1003,10 +1004,11 @@ 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. Dry-run mode is enabled by default so operators
# must explicitly opt in to irreversible deletion.
# 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.
SOFT_DELETE_RETENTION_DAYS: int = 30
SOFT_DELETE_PURGE_DRY_RUN: bool = True
SOFT_DELETE_PURGE_DRY_RUN: bool = False
# A function that receives a dict of all feature flags
# (DEFAULT_FEATURE_FLAGS merged with FEATURE_FLAGS)
+12 -1
View File
@@ -2876,6 +2876,11 @@ 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,
@@ -2891,6 +2896,8 @@ 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(
@@ -2909,10 +2916,14 @@ 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 cls.encryption_parameters.items()
if (key, value) not in encryption_items
}
encryption = all(
item in url.query.items() for item in cls.encryption_parameters.items()
+69 -20
View File
@@ -195,10 +195,15 @@ class DatabendEngineSpec(BasicParametersMixin, DatabendBaseEngineSpec):
supports_file_upload = False
sqlalchemy_uri_placeholder = (
"databend://user:password@host[:port][/dbname][?secure=value&=value...]"
"databend://user:password@host[:port][/dbname][?sslmode=value&=value...]"
)
parameters_schema = DatabendParametersSchema()
encryption_parameters = {"secure": "true"}
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"})
metadata = {
"description": (
@@ -214,7 +219,7 @@ class DatabendEngineSpec(BasicParametersMixin, DatabendBaseEngineSpec):
],
"pypi_packages": ["databend-sqlalchemy"],
"connection_string": (
"databend://{username}:{password}@{host}:{port}/{database}?secure=true"
"databend://{username}:{password}@{host}:{port}/{database}?sslmode=require"
),
"default_port": 443,
"parameters": {
@@ -259,29 +264,73 @@ class DatabendEngineSpec(BasicParametersMixin, DatabendBaseEngineSpec):
@classmethod
def build_sqlalchemy_uri(
cls, parameters: BasicParametersType, *_args: dict[str, str] | None
cls,
parameters: BasicParametersType,
encrypted_extra: dict[str, str] | None = None,
) -> str:
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))
"""
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
@classmethod
def get_parameters_from_uri(
cls, uri: str, *_args: dict[str, Any] | None
cls,
uri: str,
encrypted_extra: dict[str, Any] | None = 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 = url.query
if "secure" in query:
encryption = url.query.get("secure") == "true"
query.pop("secure")
else:
encryption = False
query = dict(url.query)
encryption = cls._encryption_from_tls_parameters(
query.pop("sslmode", None), query.pop("secure", None)
)
return BasicParametersType(
username=url.username,
password=url.password,
+16 -1
View File
@@ -165,7 +165,22 @@ 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"))
mcp.run(transport=transport, host=host, port=port, stateless_http=True)
# 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,
)
else:
mcp.run(transport=transport)
+19
View File
@@ -66,6 +66,24 @@ 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
@@ -716,6 +734,7 @@ 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,
+18 -3
View File
@@ -36,6 +36,7 @@ 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,
)
@@ -944,7 +945,11 @@ 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.
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.
Args:
host: Host to bind to
@@ -1025,13 +1030,23 @@ 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=True,
stateless_http=stateless_http,
middleware=starlette_middleware,
)
uvicorn.run(app, host=host, port=port)
@@ -1042,7 +1057,7 @@ def run_server(
transport="streamable-http",
host=host,
port=port,
stateless_http=True,
stateless_http=stateless_http,
middleware=starlette_middleware,
)
except Exception as e:
@@ -0,0 +1,163 @@
# 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)
+7 -1
View File
@@ -66,6 +66,7 @@ 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
@@ -75,6 +76,7 @@ from superset.exceptions import (
SupersetErrorException,
SupersetParseError,
SupersetSecurityException,
SupersetTemplateException,
SupersetTimeoutException,
)
from superset.extensions import cache_manager
@@ -751,6 +753,7 @@ 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
@@ -758,7 +761,10 @@ class SQLExecutor:
from superset.jinja_context import get_template_processor
tp = get_template_processor(database=self.database)
return tp.process_template(sql, **template_params)
try:
return tp.process_template(sql, **template_params)
except TemplateError as ex:
raise SupersetTemplateException(str(ex)) from ex
def _apply_limit_to_script(self, script: SQLScript, opts: QueryOptions) -> None:
"""
+4 -1
View File
@@ -17,7 +17,7 @@
import builtins
from typing import Callable, Union
from flask import g, redirect, Response, url_for
from flask import current_app, 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,6 +94,9 @@ 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,6 +19,7 @@
import re
from random import random
from unittest.mock import MagicMock
from urllib.parse import parse_qs, urlparse
import pytest
@@ -130,6 +131,27 @@ 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):
+99 -2
View File
@@ -23,7 +23,7 @@ import json # noqa: TID251
import re
from datetime import timedelta
from textwrap import dedent
from typing import Any
from typing import Any, cast
from urllib.parse import parse_qs, urlparse
import pytest
@@ -33,7 +33,11 @@ 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, convert_inspector_columns
from superset.db_engine_specs.base import (
BaseEngineSpec,
BasicParametersType,
convert_inspector_columns,
)
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import OAuth2RedirectError
from superset.sql.parse import Table
@@ -1383,3 +1387,96 @@ 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,10 +16,11 @@
# under the License.
from datetime import datetime
from typing import Any, Optional
from typing import Any, cast, Optional
from unittest.mock import Mock
import pytest
from sqlalchemy.engine.url import make_url
from sqlalchemy.types import (
Boolean,
Date,
@@ -32,6 +33,7 @@ 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,
@@ -176,3 +178,131 @@ 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"}
@@ -26,6 +26,7 @@ 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 (
@@ -129,8 +130,10 @@ 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:
# Confirm the premise: the raw value really does blow up downstream.
# Freeze away from midnight so the raw parser's since > today premise
# remains deterministic for every sub-day case.
with pytest.raises(ValueError, match="From date cannot be larger"):
get_since_until(time_range=value)
@@ -228,6 +228,27 @@ 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,8 +30,12 @@ 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=True)`` --
``mcp.http_app(transport="streamable-http", stateless_http=...)`` --
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
@@ -143,7 +147,9 @@ 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=True)``.
``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).
The request/response cycle is driven over ``httpx.ASGITransport`` (no
real socket) using FastMCP's own ``StreamableHttpTransport`` so the
@@ -165,7 +171,7 @@ async def _real_asgi_client() -> AsyncIterator[Client]:
mcp.add_middleware(middleware)
try:
asgi_app = mcp.http_app(transport="streamable-http", stateless_http=True)
asgi_app = mcp.http_app(transport="streamable-http", stateless_http=False)
def httpx_client_factory(**kwargs: Any) -> httpx.AsyncClient:
return httpx.AsyncClient(
@@ -17,8 +17,10 @@
"""Tests for MCP server EventStore creation."""
from collections.abc import Awaitable, Callable
from typing import cast
import contextlib
import os
from collections.abc import Awaitable, Callable, Iterator
from typing import Any, cast
from unittest.mock import MagicMock, patch
import pytest
@@ -316,3 +318,90 @@ 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)
@@ -0,0 +1,253 @@
# 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,6 +44,7 @@ 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
@@ -789,6 +790,42 @@ 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,11 +117,20 @@ def test_clock_uses_now_not_utcnow() -> None:
purge.assert_called_once_with(Slice, now - timedelta(days=30), False)
def test_default_config_is_safe() -> None:
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.
"""
from superset import config
assert config.SOFT_DELETE_RETENTION_DAYS == 30
assert config.SOFT_DELETE_PURGE_DRY_RUN is True
assert config.SOFT_DELETE_PURGE_DRY_RUN is False
def test_default_celery_config_registers_daily_purge() -> None: