mirror of
https://github.com/apache/superset.git
synced 2026-08-16 21:11:19 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a24fb5a0e | ||
|
|
ba09f399ac | ||
|
|
e4c306f5eb | ||
|
|
ea8fa58158 | ||
|
|
a00e2952a1 | ||
|
|
de93a19b3c | ||
|
|
57d6e5c637 | ||
|
|
0fc0d21dd3 | ||
|
|
76e6909cfd | ||
|
|
450e43b8f2 | ||
|
|
a7dd86adc6 | ||
|
|
bbd7ac7278 | ||
|
|
05af8eeff2 | ||
|
|
3f413e6e10 | ||
|
|
4278f4b3d9 | ||
|
|
b8c44a1ad5 | ||
|
|
d594a4d157 | ||
|
|
442995481c |
@@ -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"
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Vendored
+2
-2
@@ -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
@@ -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"
|
||||
|
||||
Generated
+230
-150
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
+11
-9
@@ -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};
|
||||
}
|
||||
`,
|
||||
|
||||
+12
-8
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+33
-56
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+29
-31
@@ -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"
|
||||
|
||||
+62
@@ -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};
|
||||
}
|
||||
|
||||
+33
-27
@@ -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};
|
||||
`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
+12
-8
@@ -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};
|
||||
`}
|
||||
/>
|
||||
|
||||
+6
-4
@@ -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)}
|
||||
|
||||
+5
-3
@@ -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}
|
||||
`}
|
||||
|
||||
+5
-3
@@ -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)}
|
||||
|
||||
+6
-4
@@ -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};
|
||||
`
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
+163
@@ -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)
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user