Compare commits

..

1 Commits

Author SHA1 Message Date
Joe Li
a605395147 chore: fix spacing in AGENTS.md 2026-06-09 10:58:05 -07:00
234 changed files with 8775 additions and 28663 deletions

View File

@@ -41,8 +41,8 @@ body:
label: Superset version
options:
- master / latest-dev
- "6.1.0"
- "6.0.0"
- "5.0.0"
validations:
required: true
- type: dropdown

View File

@@ -14,6 +14,12 @@ updates:
- package-ecosystem: "npm"
ignore:
# TODO: remove below entries until React >= 18.0.0
- dependency-name: "storybook"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "@storybook*"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "eslint-plugin-storybook"
- dependency-name: "react-error-boundary"
- dependency-name: "@rjsf/*"
# remark-gfm v4+ requires react-markdown v9+, which needs React 18
@@ -36,6 +42,14 @@ updates:
# and confirm the issue https://github.com/apache/superset/issues/39600 is fixed
- dependency-name: "react-checkbox-tree"
update-types: ["version-update:semver-major"]
groups:
storybook:
applies-to: version-updates
patterns:
- "@storybook*"
- "storybook"
update-types:
- "patch"
directory: "/superset-frontend/"
schedule:
interval: "daily"
@@ -76,7 +90,21 @@ updates:
- package-ecosystem: "npm"
directory: "/docs/"
ignore:
# TODO: remove below entries until React >= 18.0.0 in superset-frontend
- dependency-name: "storybook"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "@storybook*"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "eslint-plugin-storybook"
- dependency-name: "react-error-boundary"
groups:
storybook:
applies-to: version-updates
patterns:
- "@storybook*"
- "storybook"
update-types:
- "patch"
schedule:
interval: "daily"
open-pull-requests-limit: 10

View File

@@ -63,7 +63,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -74,6 +74,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
with:
category: "/language:${{matrix.language}}"

View File

@@ -48,7 +48,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: "superset-frontend/.nvmrc"
node-version: "20"
cache: "npm"
cache-dependency-path: "superset-frontend/package-lock.json"

View File

@@ -297,7 +297,7 @@ pre-commit run eslint # Frontend linting
## Platform-Specific Instructions
- **[CLAUDE.md](CLAUDE.md)** - For Claude/Anthropic tools
- **[.github/copilot-instructions.md](.github/copilot-instructions.md)** - For GitHub Copilot
- **[.github/copilot-instructions.md](.github/copilot-instructions.md)** - For GitHub Copilot
- **[GEMINI.md](GEMINI.md)** - For Google Gemini tools
- **[GPT.md](GPT.md)** - For OpenAI/ChatGPT tools
- **[.cursor/rules/dev-standard.mdc](.cursor/rules/dev-standard.mdc)** - For Cursor editor

View File

@@ -29,7 +29,7 @@ ARG BUILD_TRANSLATIONS="false"
######################################################################
# superset-node-ci used as a base for building frontend assets and CI
######################################################################
FROM --platform=${BUILDPLATFORM} node:24-trixie-slim AS superset-node-ci
FROM --platform=${BUILDPLATFORM} node:22-trixie-slim AS superset-node-ci
ARG BUILD_TRANSLATIONS
ENV BUILD_TRANSLATIONS=${BUILD_TRANSLATIONS}
ARG DEV_MODE="false" # Skip frontend build in dev mode

View File

@@ -24,27 +24,6 @@ assists people when migrating to a new version.
## Next
### Map chart renderer and OpenStreetMap migration behavior
The MapLibre migration for deck.gl charts preserves saved non-Mapbox styles on
the MapLibre-compatible path. Saved styles such as OpenStreetMap, `tile://`
tile templates, generic HTTPS style URLs, and charts without a saved style are
not reclassified as Mapbox during migration and do not require
`MAPBOX_API_KEY` only because of the migration.
Saved true Mapbox styles whose value starts with `mapbox://` remain
Mapbox-backed. If a Superset deployment does not configure `MAPBOX_API_KEY`,
those saved Mapbox charts keep the existing missing-key message instead of
silently falling back to MapLibre or another provider. In Explore, deck.gl and
point-cluster renderer controls preserve saved Mapbox state, but the Mapbox
choice is not available as a new working renderer without a configured key.
The MapLibre style choices include `Streets (OSM)`, backed by
`https://tile.openstreetmap.org/{z}/{x}/{y}.png`. This OpenStreetMap tile
service requires visible `© OpenStreetMap contributors` attribution and should
be used through normal browser map tile requests and caching; it is not intended
for bulk prefetch or offline tile downloads.
### Duration formatter precision
The `DURATION` number formatter now uses `Intl.DurationFormat` for locale-aware output. By default, sub-second fields are omitted, so values that previously displayed fractional seconds with `pretty-ms`, such as `10500` milliseconds rendering as `10.5s`, now render as `10s`.
@@ -79,22 +58,6 @@ GLOBAL_ASYNC_QUERIES_JWT_SECRET = "<output of: openssl rand -base64 42>"
The check is only active when the relevant feature is enabled, so deployments that do not use global async queries (or embedding) are not affected.
### Guest token revocation (opt-in)
Embedded guest tokens can be coarsely revoked at runtime via a new opt-in mechanism. A new config flag `GUEST_TOKEN_REVOCATION_ENABLED` (default `False`) gates the feature. When enabled, every minted guest token carries a revocation version, and tokens whose version is below the current expected version (stored in the metadata database) are rejected at validation time.
Bump the expected version with the new CLI command to invalidate all outstanding guest tokens:
```bash
superset revoke-guest-tokens
```
This change is backward compatible. The feature is off by default, and even when enabled nothing is revoked until an admin explicitly bumps the version: the expected version starts at `0`, and tokens minted before this change (which carry no version claim) are treated as version `0`. No database migration is required.
### Sessions are terminated when an account is disabled
Disabling a user account (setting `active` to `False`, via the admin UI, REST API, or CLI) now terminates that user's outstanding sessions on their next request, instead of relying on a passive check. This works for both client-side cookie sessions and server-side session stores via a per-user invalidation epoch (`user_attribute.sessions_invalidated_at`, added by a migration). The mechanism is inert for users that were never disabled (NULL epoch), so there is no behavior change for active users. Re-enabling an account and logging in again starts a fresh, valid session. The migration backfills the epoch for accounts that are already disabled at upgrade time, so re-enabling such an account does not revive a session that predates this feature.
### Dataset import validates catalog against the target connection
Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database.

View File

@@ -1 +1 @@
v24.16.0
v22.22.0

View File

@@ -109,7 +109,7 @@
"globals": "^17.6.0",
"prettier": "^3.8.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.60.1",
"typescript-eslint": "^8.60.0",
"webpack": "^5.107.2"
},
"browserslist": {

View File

@@ -4812,21 +4812,32 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.60.1", "@typescript-eslint/eslint-plugin@^8.59.3":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz#c1060bb8fa4be80624d3f3dec8dd9caca373af76"
integrity sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==
"@typescript-eslint/eslint-plugin@8.60.0", "@typescript-eslint/eslint-plugin@^8.59.3":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz#8fc1e0a950c43270eaf0212dc060f7edaa42f9cf"
integrity sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==
dependencies:
"@eslint-community/regexpp" "^4.12.2"
"@typescript-eslint/scope-manager" "8.60.1"
"@typescript-eslint/type-utils" "8.60.1"
"@typescript-eslint/utils" "8.60.1"
"@typescript-eslint/visitor-keys" "8.60.1"
"@typescript-eslint/scope-manager" "8.60.0"
"@typescript-eslint/type-utils" "8.60.0"
"@typescript-eslint/utils" "8.60.0"
"@typescript-eslint/visitor-keys" "8.60.0"
ignore "^7.0.5"
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@typescript-eslint/parser@8.60.1", "@typescript-eslint/parser@^8.60.1":
"@typescript-eslint/parser@8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.60.0.tgz#38d611b8e658cb10850d4975e8a175a222fbcd6a"
integrity sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==
dependencies:
"@typescript-eslint/scope-manager" "8.60.0"
"@typescript-eslint/types" "8.60.0"
"@typescript-eslint/typescript-estree" "8.60.0"
"@typescript-eslint/visitor-keys" "8.60.0"
debug "^4.4.3"
"@typescript-eslint/parser@^8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.60.1.tgz#a9d7f30850384d34b41f4687dd8944823c09e289"
integrity sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==
@@ -4837,6 +4848,15 @@
"@typescript-eslint/visitor-keys" "8.60.1"
debug "^4.4.3"
"@typescript-eslint/project-service@8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.60.0.tgz#b82ab12e64d005d0c7163d1240c432381f1bde0f"
integrity sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.60.0"
"@typescript-eslint/types" "^8.60.0"
debug "^4.4.3"
"@typescript-eslint/project-service@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.60.1.tgz#eb29712f58d72c222fc727162e92f2ab4670971b"
@@ -4846,6 +4866,14 @@
"@typescript-eslint/types" "^8.60.1"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz#7617a4617c043fe235dcf066f9a40f106cfd2fd5"
integrity sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==
dependencies:
"@typescript-eslint/types" "8.60.0"
"@typescript-eslint/visitor-keys" "8.60.0"
"@typescript-eslint/scope-manager@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz#2f875962eaad0a0789cc3c36aea9b4ddeb2dd9c8"
@@ -4854,7 +4882,12 @@
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/visitor-keys" "8.60.1"
"@typescript-eslint/tsconfig-utils@8.60.1":
"@typescript-eslint/tsconfig-utils@8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz#3af78c48956227a407dea9626b8db8ca53f130d2"
integrity sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==
"@typescript-eslint/tsconfig-utils@8.60.1", "@typescript-eslint/tsconfig-utils@^8.60.0":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz#bee8b942a13679a878101c9c74577d732062ed93"
integrity sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==
@@ -4864,27 +4897,47 @@
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz#05d6e3ff20001674ebcd22d03dac29ee448043ba"
integrity sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==
"@typescript-eslint/type-utils@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz#1ae45f0f2a701354beea4a58c2161e40a5e3c379"
integrity sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==
"@typescript-eslint/type-utils@8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz#6971a61bc4f3a1b2df45dcc14e26a43a88a4cb6a"
integrity sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==
dependencies:
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/typescript-estree" "8.60.1"
"@typescript-eslint/utils" "8.60.1"
"@typescript-eslint/types" "8.60.0"
"@typescript-eslint/typescript-estree" "8.60.0"
"@typescript-eslint/utils" "8.60.0"
debug "^4.4.3"
ts-api-utils "^2.5.0"
"@typescript-eslint/types@8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.60.0.tgz#e77ad768e933263b1960b2fe79de75fe1cc6e7db"
integrity sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==
"@typescript-eslint/types@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.60.1.tgz#ccdc482ba9e17f9723a10ce240b5e67dad3046c4"
integrity sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==
"@typescript-eslint/types@^8.60.1":
"@typescript-eslint/types@^8.60.0", "@typescript-eslint/types@^8.60.1":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.61.0.tgz#0ddb46e012a4288292950bdd253db42f278ce64d"
integrity sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==
"@typescript-eslint/typescript-estree@8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz#c102196a44414481190041c99eea1d854e66001b"
integrity sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==
dependencies:
"@typescript-eslint/project-service" "8.60.0"
"@typescript-eslint/tsconfig-utils" "8.60.0"
"@typescript-eslint/types" "8.60.0"
"@typescript-eslint/visitor-keys" "8.60.0"
debug "^4.4.3"
minimatch "^10.2.2"
semver "^7.7.3"
tinyglobby "^0.2.15"
ts-api-utils "^2.5.0"
"@typescript-eslint/typescript-estree@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz#016630b119228bf483ddc652703a6a038f3fdd74"
@@ -4900,15 +4953,23 @@
tinyglobby "^0.2.15"
ts-api-utils "^2.5.0"
"@typescript-eslint/utils@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.60.1.tgz#31cf566095602d9fe8ad91837d2eb520b8de762b"
integrity sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==
"@typescript-eslint/utils@8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.60.0.tgz#6110cddaef87606ae4ca6f8bf81bb5949fc8e098"
integrity sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==
dependencies:
"@eslint-community/eslint-utils" "^4.9.1"
"@typescript-eslint/scope-manager" "8.60.1"
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/typescript-estree" "8.60.1"
"@typescript-eslint/scope-manager" "8.60.0"
"@typescript-eslint/types" "8.60.0"
"@typescript-eslint/typescript-estree" "8.60.0"
"@typescript-eslint/visitor-keys@8.60.0":
version "8.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz#f2c41eedd3d7b03b808369fb2e3fb40a93783ec2"
integrity sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==
dependencies:
"@typescript-eslint/types" "8.60.0"
eslint-visitor-keys "^5.0.0"
"@typescript-eslint/visitor-keys@8.60.1":
version "8.60.1"
@@ -9300,9 +9361,9 @@ jiti@^1.20.0:
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
joi@^17.9.2:
version "17.13.4"
resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.4.tgz#ad6153d97ce558eb3a3b593e0d43eab51df1c474"
integrity sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==
version "17.13.3"
resolved "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz"
integrity sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==
dependencies:
"@hapi/hoek" "^9.3.0"
"@hapi/topo" "^5.1.0"
@@ -14389,15 +14450,15 @@ types-ramda@^0.30.1:
dependencies:
ts-toolbelt "^9.6.0"
typescript-eslint@^8.60.1:
version "8.60.1"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.60.1.tgz#13db05c6eabb89669deec44545b788a0e9aee640"
integrity sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==
typescript-eslint@^8.60.0:
version "8.60.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.60.0.tgz#6686fecb1f4f367c0bf0075828e93b7ecacbc62b"
integrity sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==
dependencies:
"@typescript-eslint/eslint-plugin" "8.60.1"
"@typescript-eslint/parser" "8.60.1"
"@typescript-eslint/typescript-estree" "8.60.1"
"@typescript-eslint/utils" "8.60.1"
"@typescript-eslint/eslint-plugin" "8.60.0"
"@typescript-eslint/parser" "8.60.0"
"@typescript-eslint/typescript-estree" "8.60.0"
"@typescript-eslint/utils" "8.60.0"
typescript@~6.0.3:
version "6.0.3"

View File

@@ -64,7 +64,7 @@ dependencies = [
"holidays>=0.45, <1",
"humanize",
"isodate",
"jsonpath-ng>=1.8.0, <2",
"jsonpath-ng>=1.6.1, <2",
"Mako>=1.2.2",
"markdown>=3.10.2",
# marshmallow>=4 has issues: https://github.com/apache/superset/issues/33162
@@ -80,7 +80,7 @@ dependencies = [
"bottleneck", # recommended performance dependency for pandas, see https://pandas.pydata.org/docs/getting_started/install.html#performance-dependencies-recommended
# --------------------------
"parsedatetime",
"paramiko>=3.4.0, <4.0", # 4.0 removed DSSKey, still referenced by sshtunnel
"paramiko>=3.4.0",
"pgsanity",
"Pillow>=11.0.0, <13",
"polyline>=2.0.0, <3.0",
@@ -89,12 +89,12 @@ dependencies = [
"python-dateutil",
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
"pygeohash",
"pyarrow>=24.0.0, <25", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
"pyarrow>=16.1.0, <21", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
"pyyaml>=6.0.0, <7.0.0",
"PyJWT>=2.4.0, <3.0",
"redis>=5.0.0, <6.0",
"rison>=2.0.0, <3.0",
"selenium>=4.44.0, <5.0",
"selenium>=4.14.0, <5.0",
"shillelagh[gsheetsapi]>=1.4.4, <2.0",
"sshtunnel>=0.4.0, <0.5",
"simplejson>=3.15.0",
@@ -107,7 +107,7 @@ dependencies = [
"typing-extensions>=4, <5",
"waitress; sys_platform == 'win32'",
"watchdog>=6.0.0",
"wtforms>=3.2.2, <4",
"wtforms>=2.3.3, <4",
"wtforms-json",
"xlsxwriter>=3.2.9, <3.3",
]
@@ -118,10 +118,10 @@ athena = ["pyathena[pandas]>=2, <4"]
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
bigquery = [
"pandas-gbq>=0.19.1",
"sqlalchemy-bigquery>=1.17.0",
"sqlalchemy-bigquery>=1.15.0",
"google-cloud-bigquery>=3.10.0",
]
clickhouse = ["clickhouse-connect>=1.1.1, <2.0"]
clickhouse = ["clickhouse-connect>=0.13.0, <2.0"]
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
crate = ["sqlalchemy-cratedb>=0.41.0, <1"]
d1 = [
@@ -143,14 +143,14 @@ duckdb = ["duckdb>=1.5.2,<2", "duckdb-engine>=0.17.0"]
dynamodb = ["pydynamodb>=0.4.2"]
solr = ["sqlalchemy-solr >= 0.2.0"]
elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
exasol = ["sqlalchemy-exasol>=2.4.0, <8.0"]
exasol = ["sqlalchemy-exasol >= 2.4.0, < 8.0"]
excel = ["xlrd>=1.2.0, <1.3"]
fastmcp = [
"fastmcp>=3.2.4,<4.0",
# tiktoken backs the response-size-guard token estimator. Without
# it, the middleware falls back to a coarser character-based
# heuristic that under-counts JSON-heavy MCP responses.
"tiktoken>=0.13.0,<1.0",
"tiktoken>=0.7.0,<1.0",
]
firebird = ["sqlalchemy-firebird>=0.7.0, <2.2"]
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
@@ -161,13 +161,13 @@ hive = [
"pyhive[hive]>=0.6.5;python_version<'3.11'",
"pyhive[hive_pure_sasl]>=0.7.0",
"tableschema",
"thrift>=0.23.0, <1.0.0",
"thrift>=0.14.1, <1.0.0",
"thrift_sasl>=0.4.3, < 1.0.0",
]
impala = ["impyla>0.16.2, <0.23"]
kusto = ["sqlalchemy-kusto>=3.1.2, <4"]
kylin = ["kylinpy>=2.8.1, <2.9"]
mssql = ["pymssql>=2.3.13, <3"]
mssql = ["pymssql>=2.2.8, <3"]
# motherduck is an alias for duckdb - MotherDuck works via the duckdb driver
motherduck = ["apache-superset[duckdb]"]
mysql = ["mysqlclient>=2.1.0, <3"]
@@ -195,7 +195,7 @@ spark = [
"pyhive[hive]>=0.6.5;python_version<'3.11'",
"pyhive[hive_pure_sasl]>=0.7",
"tableschema",
"thrift>=0.23.0, <1",
"thrift>=0.14.1, <1",
]
tdengine = [
"taospy>=2.7.21",
@@ -205,7 +205,7 @@ teradata = ["teradatasql>=16.20.0.23"]
thumbnails = [] # deprecated, will be removed in 7.0
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
netezza = ["nzalchemy>=11.0.2"]
starrocks = ["starrocks>=1.3.3, <2"]
starrocks = ["starrocks>=1.0.0"]
doris = ["pydoris>=1.0.0, <2.0.0"]
oceanbase = ["oceanbase_py>=0.0.1.2"]
ydb = ["ydb-sqlalchemy>=0.1.2", "ydb-sqlglot-plugin>=0.2.5"]
@@ -447,7 +447,6 @@ requirement_txt_file = "requirements/base.txt"
authorized_licenses = [
"academic free license (afl)",
"any-osi",
"apache-2.0",
"apache license 2.0",
"apache software",
"apache software, bsd",

View File

@@ -30,7 +30,7 @@ cryptography>=46.0.7,<47.0.0
# Security: Snyk - XSS vulnerability in Mako templates
mako>=1.3.11,<2.0.0
# Security: CVE-2024-52338 (CRITICAL) - Deserialization of untrusted data in IPC/Parquet readers
pyarrow>=24.0.0,<25.0.0
pyarrow>=20.0.0,<21.0.0
# Security: CVE-2026-27459 - pyopenssl certificate validation
pyopenssl>=26.0.0,<27.0.0
# Security: CVE-2026-25645 (MEDIUM) - Insecure Temporary File

View File

@@ -50,7 +50,7 @@ cattrs==25.1.1
# via requests-cache
celery==5.5.2
# via apache-superset (pyproject.toml)
certifi==2026.5.20
certifi==2025.6.15
# via
# requests
# selenium
@@ -194,7 +194,7 @@ jinja2==3.1.6
# via
# flask
# flask-babel
jsonpath-ng==1.8.0
jsonpath-ng==1.7.0
# via apache-superset (pyproject.toml)
jsonschema==4.23.0
# via
@@ -286,13 +286,15 @@ pillow==12.2.0
# via apache-superset (pyproject.toml)
platformdirs==4.3.8
# via requests-cache
ply==3.11
# via jsonpath-ng
polyline==2.0.2
# via apache-superset (pyproject.toml)
prison==0.2.1
# via flask-appbuilder
prompt-toolkit==3.0.51
# via click-repl
pyarrow==24.0.0
pyarrow==20.0.0
# via
# -r requirements/base.in
# apache-superset (pyproject.toml)
@@ -378,7 +380,7 @@ rpds-py==0.25.0
# referencing
rsa==4.9.1
# via google-auth
selenium==4.44.0
selenium==4.32.0
# via apache-superset (pyproject.toml)
setuptools==80.9.0
# via -r requirements/base.in
@@ -421,7 +423,7 @@ sshtunnel==0.4.0
# via apache-superset (pyproject.toml)
tabulate==0.10.0
# via apache-superset (pyproject.toml)
trio==0.33.0
trio==0.30.0
# via
# selenium
# trio-websocket
@@ -478,7 +480,7 @@ wrapt==1.17.2
# via deprecated
wsproto==1.2.0
# via trio-websocket
wtforms==3.2.2
wtforms==3.2.1
# via
# apache-superset (pyproject.toml)
# flask-appbuilder

View File

@@ -112,7 +112,7 @@ celery==5.5.2
# via
# -c requirements/base-constraint.txt
# apache-superset
certifi==2026.5.20
certifi==2025.6.15
# via
# -c requirements/base-constraint.txt
# httpcore
@@ -471,7 +471,7 @@ jmespath==1.1.0
# via
# boto3
# botocore
jsonpath-ng==1.8.0
jsonpath-ng==1.7.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -674,6 +674,10 @@ platformdirs==4.3.8
# virtualenv
pluggy==1.5.0
# via pytest
ply==3.11
# via
# -c requirements/base-constraint.txt
# jsonpath-ng
polib==1.2.0
# via apache-superset
polyline==2.0.2
@@ -711,7 +715,7 @@ psycopg2-binary==2.9.12
# via apache-superset
py-key-value-aio==0.4.4
# via fastmcp
pyarrow==24.0.0
pyarrow==20.0.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -921,7 +925,7 @@ s3transfer==0.16.0
# via boto3
secretstorage==3.5.0
# via keyring
selenium==4.44.0
selenium==4.32.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -976,7 +980,7 @@ sqlalchemy==1.4.54
# shillelagh
# sqlalchemy-bigquery
# sqlalchemy-utils
sqlalchemy-bigquery==1.17.0
sqlalchemy-bigquery==1.15.0
# via apache-superset
sqlalchemy-utils==0.42.0
# via
@@ -1007,7 +1011,7 @@ tabulate==0.10.0
# via
# -c requirements/base-constraint.txt
# apache-superset
tiktoken==0.13.0
tiktoken==0.12.0
# via apache-superset
tomli-w==1.2.0
# via apache-superset-extensions-cli
@@ -1019,7 +1023,7 @@ tqdm==4.67.1
# prophet
trino==0.330.0
# via apache-superset
trio==0.33.0
trio==0.30.0
# via
# -c requirements/base-constraint.txt
# selenium
@@ -1121,7 +1125,7 @@ wsproto==1.2.0
# via
# -c requirements/base-constraint.txt
# trio-websocket
wtforms==3.2.2
wtforms==3.2.1
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -1 +1 @@
v24.16.0
v22.22.0

View File

@@ -149,7 +149,6 @@ module.exports = {
'theme-colors/no-literal-colors': 'error',
'icons/no-fa-icons-usage': 'error',
'i18n-strings/no-template-vars': 'error',
'i18n-strings/no-eager-t-in-config': 'off', // enabled only for controlPanel files via overrides below
// Core ESLint overrides for Superset
'no-console': 'warn',
@@ -263,19 +262,6 @@ module.exports = {
],
},
overrides: [
// Eager t()/tn() in `label`/`description` config props is captured at
// module-load time, before i18n initializes — labels stay in the fallback
// language even after the user switches. Surfaced as a warning (with
// autofix to `() => t(...)`) wherever this is a real foot-gun:
// controlPanel files. Many pre-existing call sites need conversion;
// run `eslint --fix` on a controlPanel file to sweep it. Promote to
// `'error'` once the codebase is clean.
{
files: ['**/controlPanel.{ts,tsx,js,jsx}'],
rules: {
'i18n-strings/no-eager-t-in-config': 'warn',
},
},
// Ban JavaScript files in src/ - all new code must be TypeScript
{
files: ['src/**/*.js', 'src/**/*.jsx'],

View File

@@ -1 +1 @@
v24.16.0
v22.22.0

View File

@@ -1,3 +1,4 @@
import { dirname, join } from 'path';
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@@ -16,16 +17,8 @@
* specific language governing permissions and limitations
* under the License.
*/
// This file has been automatically migrated to valid ESM format by Storybook.
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
// Superset's webpack.config.js
import customConfig from '../webpack.config.js';
const require = createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const customConfig = require('../webpack.config.js');
// Filter out plugins that shouldn't be included in Storybook's static build
// ReactRefreshWebpackPlugin adds Fast Refresh code that requires a dev server runtime,
@@ -83,7 +76,7 @@ const disableDevModeInRules = rules =>
};
});
export default {
module.exports = {
stories: [
'../src/**/*.stories.tsx',
'../packages/superset-ui-core/src/**/*.stories.tsx',
@@ -91,8 +84,11 @@ export default {
],
addons: [
"@storybook/addon-links",
"@storybook/addon-docs"
getAbsolutePath('@storybook/addon-essentials'),
getAbsolutePath('@storybook/addon-links'),
'@mihkeleidast/storybook-addon-source',
getAbsolutePath('@storybook/addon-controls'),
getAbsolutePath('@storybook/addon-mdx-gfm'),
],
staticDirs: ['../src/assets/images'],
@@ -109,13 +105,11 @@ export default {
alias: {
...config.resolve?.alias,
...customConfig.resolve?.alias,
// Fix for Storybook 8.6.x with React 17 - resolve ESM module paths
'react-dom/test-utils': require.resolve('react-dom/test-utils'),
// Shared storybook utilities
'@storybook-shared': path.join(__dirname, 'shared'),
'@storybook-shared': join(__dirname, 'shared'),
},
fallback: {
tty: false,
vm: require.resolve('vm-browserify')
}
},
plugins: [...config.plugins, ...filteredPlugins],
}),
@@ -125,11 +119,15 @@ export default {
},
framework: {
name: getAbsolutePath("@storybook/react-webpack5"),
name: getAbsolutePath('@storybook/react-webpack5'),
options: {},
}
},
docs: {
autodocs: false,
},
};
function getAbsolutePath(value) {
return path.dirname(require.resolve(path.join(value, 'package.json')));
return dirname(require.resolve(join(value, 'package.json')));
}

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { withJsx } from '@mihkeleidast/storybook-addon-source';
import { themeObject, css, exampleThemes } from '@apache-superset/core/theme';
import { combineReducers, createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
@@ -113,12 +114,9 @@ const providerDecorator = Story => (
</Provider>
);
export const decorators = [themeDecorator, providerDecorator];
export const decorators = [withJsx, themeDecorator, providerDecorator];
export const parameters = {
docs: {
codePanel: true,
},
paddings: {
values: [
{ name: 'None', value: '0px' },

View File

@@ -19,7 +19,7 @@
import { useState, ReactNode, SyntheticEvent } from 'react';
import { styled } from '@apache-superset/core/theme';
import type { Decorator } from '@storybook/react-webpack5';
import type { Decorator } from '@storybook/react';
import { ResizeCallbackData } from 'react-resizable';
import ResizablePanel, { Size } from './ResizablePanel';

View File

@@ -14,7 +14,7 @@
"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.
under the License
-->
# Change Log

View File

@@ -48,7 +48,6 @@ module.exports = {
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-transform-export-namespace-from',
['@babel/plugin-transform-class-properties', { loose: true }],
'@babel/plugin-transform-class-static-block',
['@babel/plugin-transform-optional-chaining', { loose: true }],
['@babel/plugin-transform-private-methods', { loose: true }],
['@babel/plugin-transform-nullish-coalescing-operator', { loose: true }],

View File

@@ -2058,24 +2058,6 @@
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.2.tgz",
@@ -2952,8 +2934,6 @@
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"peer": true,
"dependencies": {
"sprintf-js": "~1.0.2"
}
@@ -4373,8 +4353,6 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"peer": true,
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
@@ -5616,9 +5594,7 @@
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
@@ -7780,9 +7756,7 @@
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
"dev": true,
"peer": true
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"node_modules/sshpk": {
"version": "1.18.0",
@@ -10228,23 +10202,8 @@
"camelcase": "^5.3.1",
"find-up": "^4.1.0",
"get-package-type": "^0.1.0",
"js-yaml": "4.1.1",
"js-yaml": "^3.13.1",
"resolve-from": "^5.0.0"
},
"dependencies": {
"argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"requires": {
"argparse": "^2.0.1"
}
}
}
},
"@istanbuljs/schema": {
@@ -11047,8 +11006,6 @@
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"peer": true,
"requires": {
"sprintf-js": "~1.0.2"
}
@@ -12094,9 +12051,7 @@
"esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"peer": true
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
},
"esquery": {
"version": "1.4.0",
@@ -12998,8 +12953,6 @@
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true,
"peer": true,
"requires": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
@@ -14510,9 +14463,7 @@
"sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
"dev": true,
"peer": true
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"sshpk": {
"version": "1.18.0",

View File

@@ -65,86 +65,6 @@ const plugin: { rules: Record<string, Rule.RuleModule> } = {
};
},
},
'no-eager-t-in-config': {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description:
'Disallow eager t()/tn() calls for `label` and `description` in config objects evaluated at module load (e.g., controlPanel files). The translation is captured at module-evaluation time, before i18n has loaded, and never updates when the user switches language. Wrap the call in an arrow function so it is evaluated at render time.',
},
schema: [
{
type: 'object',
properties: {
properties: {
type: 'array',
items: { type: 'string' },
},
},
additionalProperties: false,
},
],
messages: {
eager:
'Eager `{{property}}: {{fn}}(...)` is evaluated at module load, before i18n is initialized. Wrap in an arrow function: `{{property}}: () => {{fn}}(...)`.',
},
},
create(context: Rule.RuleContext): Rule.RuleListener {
const watchedProps: string[] = context.options[0]?.properties ?? [
'label',
'description',
];
const TRANSLATE_FNS = new Set(['t', 'tn']);
function handler(node: Node): void {
const prop = node as Node & {
key: { type: string; name?: string; value?: string };
value: Node & {
type: string;
callee?: { type: string; name?: string };
};
shorthand?: boolean;
computed?: boolean;
};
if (prop.shorthand || prop.computed) return;
const keyName =
prop.key.type === 'Identifier'
? prop.key.name
: prop.key.type === 'Literal'
? prop.key.value
: undefined;
if (typeof keyName !== 'string' || !watchedProps.includes(keyName)) {
return;
}
const callee = prop.value;
if (
callee.type !== 'CallExpression' ||
callee.callee?.type !== 'Identifier' ||
!callee.callee.name ||
!TRANSLATE_FNS.has(callee.callee.name)
) {
return;
}
context.report({
node: prop.value,
messageId: 'eager',
data: { property: keyName, fn: callee.callee.name },
fix(fixer) {
const source = context.getSourceCode().getText(prop.value);
return fixer.replaceText(prop.value, `() => ${source}`);
},
});
}
return {
Property: handler,
};
},
},
'sentence-case-buttons': {
meta: {
type: 'suggestion',

View File

@@ -1,86 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { Rule } from 'eslint';
const { RuleTester } = require('eslint');
const plugin: { rules: Record<string, Rule.RuleModule> } = require('.');
const ruleTester = new RuleTester({
parserOptions: { ecmaVersion: 2020, sourceType: 'module' },
});
const rule: Rule.RuleModule = plugin.rules['no-eager-t-in-config'];
ruleTester.run('no-eager-t-in-config', rule, {
valid: [
// Lazy form — the recommended pattern
"const c = { label: () => t('Foo') };",
"const c = { description: () => t('Foo') };",
"const c = { label: () => tn('one', 'many', n) };",
// Static strings — no translation, no issue
"const c = { label: 'Foo' };",
// Other property names — unaffected
"const c = { name: t('Foo') };",
"const c = { title: t('Foo') };",
// Computed keys are too dynamic to lint usefully
"const c = { [labelKey]: t('Foo') };",
// Shorthand: `{ label }` — no value to inspect
'const label = t("Foo"); const c = { label };',
// t() called inside a function body — already lazy
"const c = { label: state => t('Foo') };",
// Non-t() call expressions are fine
"const c = { label: someOtherFn('Foo') };",
],
invalid: [
{
code: "const c = { label: t('Foo') };",
output: "const c = { label: () => t('Foo') };",
errors: [{ messageId: 'eager' }],
},
{
code: "const c = { description: t('Foo bar') };",
output: "const c = { description: () => t('Foo bar') };",
errors: [{ messageId: 'eager' }],
},
{
code: "const c = { label: tn('one', 'many', 2) };",
output: "const c = { label: () => tn('one', 'many', 2) };",
errors: [{ messageId: 'eager' }],
},
// String-literal keys are equivalent to identifier keys
{
code: "const c = { 'label': t('Foo') };",
output: "const c = { 'label': () => t('Foo') };",
errors: [{ messageId: 'eager' }],
},
// Custom watched-property list via rule option
{
code: "const c = { headerTitle: t('Foo') };",
output: "const c = { headerTitle: () => t('Foo') };",
options: [{ properties: ['headerTitle'] }],
errors: [{ messageId: 'eager' }],
},
// Nested config — fires per occurrence
{
code: "const c = { foo: { label: t('A'), description: t('B') } };",
output:
"const c = { foo: { label: () => t('A'), description: () => t('B') } };",
errors: [{ messageId: 'eager' }, { messageId: 'eager' }],
},
],
});

View File

@@ -69,7 +69,7 @@ module.exports = {
],
coverageReporters: ['lcov', 'json-summary', 'html', 'text'],
transformIgnorePatterns: [
'node_modules/(?!@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.)',
'node_modules/(?!@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued)',
],
preset: 'ts-jest',
transform: {

File diff suppressed because it is too large Load Diff

View File

@@ -164,8 +164,8 @@
"@visx/scale": "^3.5.0",
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"ag-grid-community": "35.3.1",
"ag-grid-react": "35.3.1",
"ag-grid-community": "35.3.0",
"ag-grid-react": "35.3.0",
"antd": "^5.26.0",
"chrono-node": "^2.9.1",
"classnames": "^2.2.5",
@@ -178,7 +178,7 @@
"echarts": "^5.6.0",
"fast-glob": "^3.3.2",
"fs-extra": "^11.3.5",
"fuse.js": "^7.4.1",
"fuse.js": "^7.3.0",
"geolib": "^3.3.14",
"geostyler": "^18.6.0",
"geostyler-data": "^1.1.0",
@@ -261,14 +261,22 @@
"@babel/types": "^7.29.7",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/jest": "^11.14.2",
"@formatjs/intl-durationformat": "^0.10.13",
"@formatjs/intl-durationformat": "^0.10.3",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.60.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.4.2",
"@storybook/addon-links": "10.4.2",
"@storybook/react-webpack5": "10.4.2",
"@storybook/test-runner": "0.24.4",
"@storybook/addon-actions": "^8.6.18",
"@storybook/addon-controls": "^8.6.18",
"@storybook/addon-essentials": "^8.6.18",
"@storybook/addon-links": "^8.6.18",
"@storybook/addon-mdx-gfm": "^8.6.18",
"@storybook/components": "^8.6.18",
"@storybook/preview-api": "^8.6.18",
"@storybook/react": "^8.6.18",
"@storybook/react-webpack5": "^8.6.18",
"@storybook/test": "^8.6.18",
"@storybook/test-runner": "^0.17.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.40",
"@swc/plugin-emotion": "^14.12.0",
@@ -289,6 +297,7 @@
"@types/react-dom": "^18.2.0",
"@types/react-loadable": "^5.5.11",
"@types/react-redux": "^7.1.10",
"@types/react-resizable": "^4.0.0",
"@types/react-router-dom": "^5.3.3",
"@types/react-transition-group": "^4.4.12",
"@types/react-window": "^1.8.8",
@@ -325,7 +334,7 @@
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.4",
"eslint-plugin-storybook": "10.4.2",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
@@ -355,7 +364,7 @@
"source-map": "^0.7.6",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.4.2",
"storybook": "8.6.18",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.1",
@@ -382,8 +391,8 @@
"regenerator-runtime": "^0.14.1"
},
"engines": {
"node": "^24.16.0",
"npm": "^11.13.0"
"node": "^22.22.0",
"npm": "^10.8.1"
},
"overrides": {
"uuid": "$uuid",

View File

@@ -37,7 +37,7 @@
* ```
*/
import { Disposable, Event } from '../common';
import { Disposable } from '../common';
/**
* Represents a menu item that links a view to a command.
@@ -102,37 +102,3 @@ export declare function registerMenuItem(
* ```
*/
export declare function getMenu(location: string): Menu | undefined;
/**
* Event fired when a menu item is registered.
*/
export interface MenuItemRegisteredEvent {
/** The menu item that was registered. */
item: MenuItem;
/** The location where the item was registered. */
location: string;
/** The group the item was placed in. */
group: 'primary' | 'secondary' | 'context';
}
/**
* Event fired when a menu item is unregistered.
*/
export interface MenuItemUnregisteredEvent {
/** The menu item that was unregistered. */
item: MenuItem;
/** The location where the item was registered. */
location: string;
/** The group the item was placed in. */
group: 'primary' | 'secondary' | 'context';
}
/**
* Event fired when a menu item is registered.
*/
export declare const onDidRegisterMenuItem: Event<MenuItemRegisteredEvent>;
/**
* Event fired when a menu item is unregistered.
*/
export declare const onDidUnregisterMenuItem: Event<MenuItemUnregisteredEvent>;

View File

@@ -26,7 +26,7 @@ test('t() warns and creates a default translator when called before configure',
const { t } = require('./TranslatorSingleton');
const result = t('hello');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringMatching(/was called before configure\(\)/),
'You should call configure(...) before calling other methods',
);
expect(result).toBe('hello');
consoleSpy.mockRestore();
@@ -54,7 +54,7 @@ test('resetTranslation resets the configured singleton', () => {
// After reset, calling t() should warn again
t('hello');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringMatching(/was called before configure\(\)/),
'You should call configure(...) before calling other methods',
);
consoleSpy.mockRestore();
});
@@ -96,69 +96,6 @@ test('tn() calls translateWithNumber on the singleton', () => {
});
});
test('pre-configure warning fires once per unique key', () => {
jest.isolateModules(() => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const { t } = require('./TranslatorSingleton');
t('apple');
t('apple');
t('apple');
t('banana');
expect(consoleSpy).toHaveBeenCalledTimes(2);
expect(consoleSpy).toHaveBeenNthCalledWith(
1,
expect.stringContaining('"apple"'),
);
expect(consoleSpy).toHaveBeenNthCalledWith(
2,
expect.stringContaining('"banana"'),
);
consoleSpy.mockRestore();
});
});
test('pre-configure warning suggests the lazy-function fix', () => {
jest.isolateModules(() => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const { t } = require('./TranslatorSingleton');
t('Sort ascending');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('() => t("Sort ascending")'),
);
consoleSpy.mockRestore();
});
});
test('pre-configure warning is suppressed in production', () => {
jest.isolateModules(() => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const { t } = require('./TranslatorSingleton');
t('hello');
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
if (originalEnv !== undefined) {
process.env.NODE_ENV = originalEnv;
} else {
delete process.env.NODE_ENV;
}
});
});
test('resetTranslation clears the warned-keys dedupe set', () => {
jest.isolateModules(() => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const { t, resetTranslation } = require('./TranslatorSingleton');
t('hello');
expect(consoleSpy).toHaveBeenCalledTimes(1);
resetTranslation();
t('hello');
expect(consoleSpy).toHaveBeenCalledTimes(2);
consoleSpy.mockRestore();
});
});
test('resetTranslation does nothing when not yet configured', () => {
jest.isolateModules(() => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
@@ -168,7 +105,7 @@ test('resetTranslation does nothing when not yet configured', () => {
// The singleton is still unconfigured, so t() warns
t('hello');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringMatching(/was called before configure\(\)/),
'You should call configure(...) before calling other methods',
);
consoleSpy.mockRestore();
});

View File

@@ -25,10 +25,6 @@ import { TranslatorConfig, Translations, LocaleData } from './types';
let singleton: Translator | undefined;
let isConfigured = false;
// Tracks which keys have already triggered a pre-configure warning so the
// logs don't drown in repeated calls from large module-load fan-outs.
const warnedPreConfigureKeys = new Set<string>();
function configure(config?: TranslatorConfig) {
singleton = new Translator(config);
isConfigured = true;
@@ -37,6 +33,10 @@ function configure(config?: TranslatorConfig) {
}
function getInstance() {
if (!isConfigured) {
console.warn('You should call configure(...) before calling other methods');
}
if (typeof singleton === 'undefined') {
singleton = new Translator();
}
@@ -44,32 +44,11 @@ function getInstance() {
return singleton;
}
function warnPreConfigure(fn: 't' | 'tn', key: string) {
// Only warn in non-production builds — production callers may legitimately
// tolerate the fallback, and the noise isn't useful at runtime.
if (
typeof process !== 'undefined' &&
process.env?.NODE_ENV === 'production'
) {
return;
}
if (warnedPreConfigureKeys.has(key)) return;
warnedPreConfigureKeys.add(key);
console.warn(
`[i18n] ${fn}(${JSON.stringify(key)}) was called before configure() — ` +
`the result is the fallback language and will not update when the ` +
`user switches language. If this call is at module load (e.g., a ` +
`controlPanel \`label\`/\`description\`), wrap it in an arrow ` +
`function: \`() => ${fn}(${JSON.stringify(key)})\`.`,
);
}
function resetTranslation() {
if (isConfigured) {
isConfigured = false;
singleton = undefined;
}
warnedPreConfigureKeys.clear();
}
function addTranslation(key: string, translations: string[]) {
@@ -85,12 +64,10 @@ function addLocaleData(data: LocaleData) {
}
function t(input: string, ...args: unknown[]) {
if (!isConfigured) warnPreConfigure('t', input);
return getInstance().translate(input, ...args);
}
function tn(key: string, ...args: unknown[]) {
if (!isConfigured) warnPreConfigure('tn', key);
return getInstance().translateWithNumber(key, ...args);
}

View File

@@ -36,7 +36,7 @@
*/
import { ReactElement } from 'react';
import { Disposable, Event } from '../common';
import { Disposable } from '../common';
/**
* Represents a contributed view in the application.
@@ -88,33 +88,3 @@ export declare function registerView(
* ```
*/
export declare function getViews(location: string): View[] | undefined;
/**
* Event fired when a view is registered.
*/
export interface ViewRegisteredEvent {
/** The descriptor of the view that was registered. */
view: View;
/** The location where the view was registered. */
location: string;
}
/**
* Event fired when a view is unregistered.
*/
export interface ViewUnregisteredEvent {
/** The descriptor of the view that was unregistered. */
view: View;
/** The location where the view was registered. */
location: string;
}
/**
* Event fired when a view is registered.
*/
export declare const onDidRegisterView: Event<ViewRegisteredEvent>;
/**
* Event fired when a view is unregistered.
*/
export declare const onDidUnregisterView: Event<ViewUnregisteredEvent>;

View File

@@ -204,14 +204,8 @@ export type TabOverride = 'data' | 'customize' | 'matrixify' | boolean;
* these configs will be passed to the UI component for control as props.
*
* - type: the control type, referencing a React component of the same name
* - label: the label as shown in the control's header. When the value involves
* `t()`/`tn()`, prefer the arrow-function form (`label: () => t('Foo')`) so
* the lookup runs at render time rather than at module load — eager
* `label: t('Foo')` captures the fallback language before i18n initializes
* and does not update on runtime language change. The
* `i18n-strings/no-eager-t-in-config` lint rule autofixes this.
* - description: shown in the info tooltip of the control's header. Same
* lazy-form guidance as `label`.
* - label: the label as shown in the control's header
* - description: shown in the info tooltip of the control's header
* - default: the default value when opening a new chart, or changing visualization type
* - renderTrigger: a bool that defines whether the visualization should be re-rendered
* when changed. This should `true` for controls that only affect the rendering (client side)

View File

@@ -31,8 +31,8 @@
"@types/json-bigint": "^1.0.4",
"@visx/responsive": "^3.12.0",
"ace-builds": "^1.44.0",
"ag-grid-community": "35.3.1",
"ag-grid-react": "35.3.1",
"ag-grid-community": "35.3.0",
"ag-grid-react": "35.3.0",
"brace": "^0.11.1",
"classnames": "^2.5.1",
"core-js": "^3.49.0",
@@ -43,7 +43,7 @@
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.21",
"dompurify": "^3.4.8",
"dompurify": "^3.4.7",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
"jed": "^1.1.1",

View File

@@ -72,7 +72,6 @@ test('should generate a 2x2 grid for metrics mode', () => {
createAdhocMetric('Revenue'),
createSqlMetric('Q1', 'SUM(CASE WHEN quarter = 1 THEN value END)'),
]);
expect(firstCell!.formData.metric).toEqual(createAdhocMetric('Revenue'));
});
test('should generate grid for dimensions mode', () => {
@@ -214,9 +213,6 @@ test('should skip missing column metrics when generating cell form data', () =>
expect(grid!.cells[0][0]!.formData.metrics).toEqual([
createAdhocMetric('Revenue'),
]);
expect(grid!.cells[0][0]!.formData.metric).toEqual(
createAdhocMetric('Revenue'),
);
});
test('should not escape HTML entities in cell titles', () => {
@@ -475,51 +471,6 @@ test('should handle metrics without labels', () => {
expect(grid!.colHeaders).toEqual(['count']);
});
test('should set singular metric for singular-metric chart types like Pie', () => {
const rowMetricFormData: TestFormData = {
viz_type: 'pie',
datasource: '1__table',
matrixify_enable: true,
matrixify_mode_rows: 'metrics',
matrixify_rows: [createAdhocMetric('Revenue'), createAdhocMetric('Profit')],
};
const grid = generateMatrixifyGrid(rowMetricFormData);
expect(grid).not.toBeNull();
expect(grid!.cells[0][0]!.formData.metrics).toEqual([
createAdhocMetric('Revenue'),
]);
expect(grid!.cells[0][0]!.formData.metric).toEqual(
createAdhocMetric('Revenue'),
);
expect(grid!.cells[1][0]!.formData.metrics).toEqual([
createAdhocMetric('Profit'),
]);
expect(grid!.cells[1][0]!.formData.metric).toEqual(
createAdhocMetric('Profit'),
);
});
test('should not overwrite singular metric in dimension-only mode', () => {
const dimensionFormData: TestFormData = {
viz_type: 'pie',
datasource: '1__table',
matrixify_enable: true,
matrixify_mode_rows: 'dimensions',
matrixify_dimension_rows: {
dimension: 'country',
values: ['USA', 'Canada'],
},
metric: 'existing_metric',
};
const grid = generateMatrixifyGrid(dimensionFormData);
expect(grid).not.toBeNull();
expect(grid!.cells[0][0]!.formData.metric).toBe('existing_metric');
});
test('should preserve slice_id and dashboardId for embedded dashboard permissions', () => {
const formDataWithDashboardContext: TestFormData = {
...baseFormData,

View File

@@ -197,7 +197,6 @@ function generateCellFormData(
// If we have metrics from the matrix, use them; otherwise keep original
if (metrics.length > 0) {
cellFormData.metrics = metrics;
cellFormData.metric = metrics[0];
}
return cellFormData;

View File

@@ -17,20 +17,8 @@
* under the License.
*/
import {
forwardRef,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
} from 'react';
import type {
ComponentType,
WeakValidationMap,
ForwardRefExoticComponent,
PropsWithoutRef,
RefAttributes,
} from 'react';
// eslint-disable-next-line no-restricted-syntax -- whole React import is required for `reactify.test.tsx` Jest test passing.
import { Component, ComponentClass, WeakValidationMap } from 'react';
// TODO: Note that id and className can collide between Props and ReactifyProps
// leading to (likely) unexpected behaviors. We should either require Props to not
@@ -61,103 +49,66 @@ export interface RenderFuncType<Props> {
propTypes?: WeakValidationMap<Props & ReactifyProps>;
}
export interface ReactifiedComponentRef {
container?: HTMLDivElement;
}
export type ReactifiedComponent<Props> = ForwardRefExoticComponent<
PropsWithoutRef<Props & ReactifyProps> & RefAttributes<ReactifiedComponentRef>
>;
// Return the widest public type that covers "use it as a React component" so
// TypeScript JSX callers and `ComponentType<...>`-typed variables still compile;
// callers with explicit `ComponentClass<...>` annotations must widen to
// `ComponentType`. Those wanting the forwardRef surface can narrow to
// `ReactifiedComponent<Props>` explicitly.
export default function reactify<Props extends object>(
renderFn: RenderFuncType<Props>,
callbacks?: LifeCycleCallbacks,
): ComponentType<Props & ReactifyProps> {
const ReactifiedComponent = forwardRef<
ReactifiedComponentRef,
Props & ReactifyProps
>(function ReactifiedComponent(props, ref) {
const containerRef = useRef<HTMLDivElement>(null);
// Keep the latest props available to the unmount callback — legacy
// consumers read values off `this.props` (e.g. ReactNVD3 uses id).
// Update the ref in a layout effect rather than during render so the
// assignment only happens for committed renders (safe under Concurrent
// Mode) and is in place before the passive unmount effect reads it.
const propsRef = useRef(props);
useLayoutEffect(() => {
propsRef.current = props;
});
): ComponentClass<Props & ReactifyProps> {
class ReactifiedComponent extends Component<Props & ReactifyProps> {
container?: HTMLDivElement;
// Expose container via ref for external access
useImperativeHandle(
ref,
() => ({
get container() {
return containerRef.current ?? undefined;
},
}),
[],
);
constructor(props: Props & ReactifyProps) {
super(props);
this.setContainerRef = this.setContainerRef.bind(this);
}
// Execute renderFn on mount and every update (mimics componentDidMount + componentDidUpdate)
useEffect(() => {
if (containerRef.current) {
// `forwardRef` widens the props parameter to `PropsWithoutRef<...>`,
// which TypeScript can't narrow back to `Props & ReactifyProps` when
// `Props` is a generic `object`. The values are identical at runtime,
// so assert the original prop shape for `renderFn`.
renderFn(
containerRef.current,
props as Readonly<Props & ReactifyProps>,
);
componentDidMount() {
this.execute();
}
componentDidUpdate() {
this.execute();
}
componentWillUnmount() {
this.container = undefined;
if (callbacks?.componentWillUnmount) {
callbacks.componentWillUnmount.bind(this)();
}
});
}
// Cleanup on unmount
useEffect(
() => () => {
if (callbacks?.componentWillUnmount) {
// Preserve legacy behavior where `this` was a component instance
// exposing `props`. The class version cleared `this.container`
// before invoking componentWillUnmount, so mirror that here to
// prevent callbacks from touching a DOM node that's being torn
// down.
callbacks.componentWillUnmount.call({
container: undefined,
props: propsRef.current,
});
}
},
[],
);
setContainerRef(ref: HTMLDivElement) {
this.container = ref;
}
const { id, className } = props;
execute() {
if (this.container) {
renderFn(this.container, this.props);
}
}
return <div ref={containerRef} id={id} className={className} />;
});
render() {
const { id, className } = this.props;
if (renderFn.displayName) {
ReactifiedComponent.displayName = renderFn.displayName;
return <div ref={this.setContainerRef} id={id} className={className} />;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- forwardRef static field types don't line up with renderFn's validator types
const result = ReactifiedComponent as any;
const ReactifiedClass: ComponentClass<Props & ReactifyProps> =
ReactifiedComponent;
if (renderFn.displayName) {
ReactifiedClass.displayName = renderFn.displayName;
}
// eslint-disable-next-line react/forbid-foreign-prop-types
if (renderFn.propTypes) {
result.propTypes = {
...result.propTypes,
ReactifiedClass.propTypes = {
...ReactifiedClass.propTypes,
...renderFn.propTypes,
};
}
if (renderFn.defaultProps) {
result.defaultProps = renderFn.defaultProps;
ReactifiedClass.defaultProps = renderFn.defaultProps;
}
return result as unknown as ComponentType<Props & ReactifyProps>;
return ReactifiedComponent;
}

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import type { Meta, StoryObj } from '@storybook/react';
import { AutoComplete } from '.';
import type { AutoCompleteProps } from './types';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import type { Meta, StoryObj } from '@storybook/react';
import { Breadcrumb } from '.';
import type { BreadcrumbProps } from './types';

View File

@@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { action } from 'storybook/actions';
import { Meta, StoryFn } from '@storybook/react-webpack5';
import { action } from '@storybook/addon-actions';
import { Meta, StoryFn } from '@storybook/react';
import { CachedLabel } from '.';
import type { CacheLabelProps } from './types';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useArgs } from 'storybook/preview-api';
import { useArgs } from '@storybook/preview-api';
import { useState } from 'react';
import { Checkbox } from '.';
import type { CheckboxProps, CheckboxChangeEvent } from './types';

View File

@@ -17,7 +17,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Meta, StoryFn } from '@storybook/react-webpack5';
import { Meta, StoryFn } from '@storybook/react';
import { Row, Col } from '@superset-ui/core/components';
import { EmptyState, imageMap } from '.';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import type { Meta, StoryObj } from '@storybook/react';
import { FaveStar } from '.';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Meta, StoryObj } from '@storybook/react-webpack5';
import { Meta, StoryObj } from '@storybook/react';
import Slider from '@superset-ui/core/components/Slider/index';
import { useState } from 'react';
import { Row, Col } from '.';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Meta, StoryObj } from '@storybook/react-webpack5';
import { Meta, StoryObj } from '@storybook/react';
import { IconButton } from '.';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { StoryObj } from '@storybook/react-webpack5';
import type { StoryObj } from '@storybook/react';
import { Input, InputNumber } from '.';
import type { InputProps, InputNumberProps, TextAreaProps } from './types';

View File

@@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { action } from 'storybook/actions';
import { Meta, StoryFn } from '@storybook/react-webpack5';
import { action } from '@storybook/addon-actions';
import { Meta, StoryFn } from '@storybook/react';
import type { LabelType } from './types';
import { Label, DatasetTypeLabel, PublishedLabel } from '.';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Meta, StoryObj } from '@storybook/react-webpack5';
import { Meta, StoryObj } from '@storybook/react';
import { Icons } from '@superset-ui/core/components/Icons';
import { Menu } from '../Menu';
import type { LayoutProps, SiderProps } from './types';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import type { Meta, StoryObj } from '@storybook/react';
import { ListViewCard } from '.';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { StoryObj } from '@storybook/react-webpack5';
import type { StoryObj } from '@storybook/react';
import { css } from '@apache-superset/core/theme';
import { Icons } from '@superset-ui/core/components/Icons';
import { Space } from '../Space';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Meta, StoryFn } from '@storybook/react-webpack5';
import { Meta, StoryFn } from '@storybook/react';
import { SafeMarkdown } from './SafeMarkdown';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import type { Meta, StoryObj } from '@storybook/react';
import { Space } from '../Space';
import { Skeleton, type SkeletonProps } from '.';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useArgs } from 'storybook/preview-api';
import { useArgs } from '@storybook/preview-api';
import { Switch, type SwitchProps } from '.';
export default {

View File

@@ -18,8 +18,8 @@
*/
import { useState, DragEvent } from 'react';
import type { Meta, StoryFn } from '@storybook/react-webpack5';
import { action } from 'storybook/actions';
import type { Meta, StoryFn } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import {
ColumnsType,
ETableAction,

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { StoryFn, Meta } from '@storybook/react-webpack5';
import { StoryFn, Meta } from '@storybook/react';
import ActionCell from './index';
import { exampleMenuOptions, exampleRow } from './fixtures';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { action } from 'storybook/actions';
import { action } from '@storybook/addon-actions';
import { ActionMenuItem } from './index';
export const exampleMenuOptions: ActionMenuItem[] = [

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { StoryFn, Meta } from '@storybook/react-webpack5';
import { StoryFn, Meta } from '@storybook/react';
import BooleanCell from '.';
export default {

View File

@@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { StoryFn, Meta } from '@storybook/react-webpack5';
import { action } from 'storybook/actions';
import { StoryFn, Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { ButtonCell } from './index';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { StoryFn, Meta } from '@storybook/react-webpack5';
import { StoryFn, Meta } from '@storybook/react';
import NullCell from '.';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { StoryFn, Meta } from '@storybook/react-webpack5';
import { StoryFn, Meta } from '@storybook/react';
import { CurrencyCode, NumericCell, LocaleCode, Style } from './index';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { StoryFn, Meta } from '@storybook/react-webpack5';
import { StoryFn, Meta } from '@storybook/react';
import { TimeFormats } from '@superset-ui/core';
import TimeCell from '.';

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { useMemo, useState, useCallback } from 'react';
import { Meta, StoryFn } from '@storybook/react-webpack5';
import { Meta, StoryFn } from '@storybook/react';
import {
useTable,
useSortBy,

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useArgs } from 'storybook/preview-api';
import { useArgs } from '@storybook/preview-api';
import TimezoneSelector, { TimezoneSelectorProps } from './index';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { StoryObj } from '@storybook/react-webpack5';
import { StoryObj } from '@storybook/react';
import { Icons } from '@superset-ui/core/components/Icons';
import Tree, { TreeProps, type TreeDataNode } from './index';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Meta, StoryObj } from '@storybook/react-webpack5';
import { Meta, StoryObj } from '@storybook/react';
import { TreeSelect, type TreeSelectProps } from '.';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import type { Meta, StoryObj } from '@storybook/react';
import { Typography } from '.';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { StoryObj } from '@storybook/react-webpack5';
import type { StoryObj } from '@storybook/react';
import { Icons } from '../Icons';
import { Button } from '../Button';
import { Upload } from '.';

View File

@@ -35,4 +35,3 @@ export * from './typedMemo';
export * from './html';
export * from './tooltip';
export * from './merge';
export * from './mapStyles';

View File

@@ -1,295 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
getBootstrapDataFromDocument,
getDefaultMapRenderer,
getMapProviderMapStyle,
getMapboxApiKeyFromBootstrap,
getMapRendererOptions,
hasMapboxApiKey,
isRasterTileTemplate,
OSM_TILE_ATTRIBUTION,
OSM_TILE_STYLE_URL,
resolveMapStyle,
} from './mapStyles';
test('OSM style metadata uses the approved URL and attribution', () => {
expect(OSM_TILE_STYLE_URL).toBe(
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
);
expect(OSM_TILE_ATTRIBUTION).toBe('© OpenStreetMap contributors');
});
test('Mapbox key helpers report absence and presence from bootstrap data', () => {
expect(getMapboxApiKeyFromBootstrap({ common: { conf: {} } })).toBe('');
expect(hasMapboxApiKey({ common: { conf: {} } })).toBe(false);
expect(
getMapboxApiKeyFromBootstrap({
common: { conf: { MAPBOX_API_KEY: 'pk.test' } },
}),
).toBe('pk.test');
expect(
getMapboxApiKeyFromBootstrap({
common: { conf: { MAPBOX_API_KEY: ' pk.test ' } },
}),
).toBe('pk.test');
expect(hasMapboxApiKey({ common: { conf: { MAPBOX_API_KEY: ' ' } } })).toBe(
false,
);
expect(
hasMapboxApiKey({ common: { conf: { MAPBOX_API_KEY: 'pk.test' } } }),
).toBe(true);
});
test('bootstrap data helper parses document data safely', () => {
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
common: { conf: { MAPBOX_API_KEY: 'pk.document' } },
})}'></div>`;
expect(getBootstrapDataFromDocument()).toEqual({
common: { conf: { MAPBOX_API_KEY: 'pk.document' } },
});
document.body.innerHTML = `<div id="app" data-bootstrap='not-json'></div>`;
expect(getBootstrapDataFromDocument()).toBeUndefined();
document.body.innerHTML = '';
expect(getBootstrapDataFromDocument()).toBeUndefined();
});
test('bootstrap data helper returns undefined without a document', () => {
// jsdom defines `document` as a non-configurable global, so the SSR guard
// cannot be exercised by deleting it. Instead, re-evaluate the function's
// own source in a scope where `document` is shadowed with undefined. When
// running under coverage, the source is istanbul-instrumented and references
// its module-scoped counter, so the counter is injected to keep the guard's
// execution attributed to mapStyles.ts.
const source = getBootstrapDataFromDocument.toString();
const counterName = source.match(/cov_\w+/)?.[0] ?? 'unusedCoverageCounter';
const coverage = (globalThis as { __coverage__?: Record<string, unknown> })
.__coverage__;
const coverageEntry =
coverage?.[
Object.keys(coverage).find(file => file.endsWith('mapStyles.ts')) ?? ''
];
// eslint-disable-next-line no-new-func
const callWithoutDocument = new Function(
counterName,
'document',
`return (${source})();`,
);
expect(callWithoutDocument(() => coverageEntry, undefined)).toBeUndefined();
});
test('renderer options enable Mapbox only when a key is available', () => {
expect(getMapRendererOptions({ hasMapboxKey: true })).toEqual([
{ value: 'maplibre' },
{ value: 'mapbox' },
]);
expect(getMapRendererOptions({ hasMapboxKey: false })).toEqual([
{ value: 'maplibre' },
]);
});
test('renderer options preserve saved Mapbox without API-key labels', () => {
expect(
getMapRendererOptions({ hasMapboxKey: false, currentValue: 'mapbox' }),
).toEqual([{ value: 'maplibre' }, { value: 'mapbox', disabled: true }]);
});
test('map provider style helper preserves legacy non-Mapbox styles for MapLibre', () => {
expect(
getMapProviderMapStyle({
mapProvider: 'maplibre',
maplibreStyle: undefined,
mapboxStyle: OSM_TILE_STYLE_URL,
legacyMapStyle: 'https://example.com/fallback-style.json',
}),
).toEqual({
mapProvider: 'maplibre',
mapStyle: OSM_TILE_STYLE_URL,
});
});
test('map provider style helper does not send Mapbox URLs to MapLibre', () => {
expect(
getMapProviderMapStyle({
mapProvider: 'maplibre',
mapboxStyle: 'mapbox://styles/mapbox/dark-v11',
legacyMapStyle: 'https://example.com/fallback-style.json',
}),
).toEqual({
mapProvider: 'maplibre',
mapStyle: 'https://example.com/fallback-style.json',
});
});
test('map provider style helper uses Mapbox style when Mapbox is selected', () => {
expect(
getMapProviderMapStyle({
mapProvider: 'mapbox',
mapboxStyle: 'mapbox://styles/mapbox/dark-v11',
legacyMapStyle: 'https://example.com/fallback-style.json',
}),
).toEqual({
mapProvider: 'mapbox',
mapStyle: 'mapbox://styles/mapbox/dark-v11',
});
});
test('default renderer uses configured Mapbox only when a key is available', () => {
expect(
getDefaultMapRenderer({
common: {
conf: {
DEFAULT_MAP_RENDERER: 'mapbox',
MAPBOX_API_KEY: 'pk.test',
},
},
}),
).toBe('mapbox');
expect(
getDefaultMapRenderer({
common: { conf: { DEFAULT_MAP_RENDERER: 'mapbox' } },
}),
).toBe('maplibre');
expect(
getDefaultMapRenderer({
common: {
conf: {
DEFAULT_MAP_RENDERER: 'invalid',
MAPBOX_API_KEY: 'pk.test',
},
},
}),
).toBe('maplibre');
});
test('raster tile templates resolve to MapLibre raster style objects with attribution', () => {
const style = resolveMapStyle(OSM_TILE_STYLE_URL, 'default-style.json');
expect(style).toEqual({
version: 8,
sources: {
'osm-raster-tiles': {
type: 'raster',
tiles: [OSM_TILE_STYLE_URL],
tileSize: 256,
attribution: OSM_TILE_ATTRIBUTION,
},
},
layers: [
{
id: 'osm-raster-layer',
type: 'raster',
source: 'osm-raster-tiles',
minzoom: 0,
maxzoom: 22,
},
],
});
});
test('tile protocol raster templates are unwrapped before style resolution', () => {
const style = resolveMapStyle(
`tile://${OSM_TILE_STYLE_URL}`,
'default-style.json',
);
expect(typeof style).toBe('object');
if (typeof style !== 'string') {
expect(style.sources['osm-raster-tiles'].tiles).toEqual([
OSM_TILE_STYLE_URL,
]);
expect(style.sources['osm-raster-tiles'].attribution).toBe(
OSM_TILE_ATTRIBUTION,
);
}
});
test('OpenStreetMap subdomain raster templates receive OSM attribution', () => {
const osmSubdomainTileUrl =
'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png';
const style = resolveMapStyle(
`tile://${osmSubdomainTileUrl}`,
'default-style.json',
);
expect(typeof style).toBe('object');
if (typeof style !== 'string') {
expect(style.sources['osm-raster-tiles'].tiles).toEqual([
osmSubdomainTileUrl,
]);
expect(style.sources['osm-raster-tiles'].attribution).toBe(
OSM_TILE_ATTRIBUTION,
);
}
});
test('custom raster tile templates do not receive OSM attribution', () => {
const customTileUrl = 'https://tiles.example.com/{z}/{x}/{y}.png';
const style = resolveMapStyle(
`tile://${customTileUrl}`,
'default-style.json',
);
expect(typeof style).toBe('object');
if (typeof style !== 'string') {
expect(style.sources['osm-raster-tiles'].tiles).toEqual([customTileUrl]);
expect(style.sources['osm-raster-tiles']).not.toHaveProperty('attribution');
}
});
test('relative raster tile templates do not receive OSM attribution', () => {
const relativeTileUrl = '/tiles/{z}/{x}/{y}.png';
const style = resolveMapStyle(relativeTileUrl, 'default-style.json');
expect(typeof style).toBe('object');
if (typeof style !== 'string') {
expect(style.sources['osm-raster-tiles'].tiles).toEqual([relativeTileUrl]);
expect(style.sources['osm-raster-tiles']).not.toHaveProperty('attribution');
}
});
test('lookalike OpenStreetMap hostnames do not receive OSM attribution', () => {
const lookalikeTileUrl =
'https://openstreetmap.org.example.com/{z}/{x}/{y}.png';
const style = resolveMapStyle(
`tile://${lookalikeTileUrl}`,
'default-style.json',
);
expect(typeof style).toBe('object');
if (typeof style !== 'string') {
expect(style.sources['osm-raster-tiles'].tiles).toEqual([lookalikeTileUrl]);
expect(style.sources['osm-raster-tiles']).not.toHaveProperty('attribution');
}
});
test('style JSON URLs pass through without raster wrapping', () => {
const styleUrl = 'https://example.com/styles/custom-style.json';
expect(isRasterTileTemplate(undefined)).toBe(false);
expect(isRasterTileTemplate(styleUrl)).toBe(false);
expect(resolveMapStyle(styleUrl, 'default-style.json')).toBe(styleUrl);
expect(resolveMapStyle(undefined, 'default-style.json')).toBe(
'default-style.json',
);
});

View File

@@ -1,251 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export type MapProvider = 'maplibre' | 'mapbox';
export type MapRendererOption = {
value: MapProvider;
disabled?: boolean;
};
export type MapProviderMapStyle = {
mapProvider?: unknown;
maplibreStyle?: unknown;
mapboxStyle?: unknown;
legacyMapStyle?: unknown;
};
export type SelectedMapProviderMapStyle = {
mapProvider: MapProvider;
mapStyle?: string;
};
export type RasterTileMapStyle = {
version: 8;
sources: {
[sourceId: string]: {
type: 'raster';
tiles: string[];
tileSize: 256;
attribution?: string;
};
};
layers: [
{
id: string;
type: 'raster';
source: string;
minzoom: 0;
maxzoom: 22;
},
];
};
export type ResolvedMapStyle = string | RasterTileMapStyle;
export const OSM_TILE_STYLE_URL =
'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
export const OSM_TILE_ATTRIBUTION = '© OpenStreetMap contributors';
export const MAPLIBRE_RENDERER_OPTION: MapRendererOption = {
value: 'maplibre',
};
export const MAPBOX_RENDERER_OPTION: MapRendererOption = {
value: 'mapbox',
};
export const DISABLED_MAPBOX_RENDERER_OPTION: MapRendererOption = {
...MAPBOX_RENDERER_OPTION,
disabled: true,
};
const TILE_PROTOCOL = 'tile://';
const RASTER_SOURCE_ID = 'osm-raster-tiles';
const RASTER_LAYER_ID = 'osm-raster-layer';
type BootstrapData = {
common?: {
conf?: {
DEFAULT_MAP_RENDERER?: unknown;
MAPBOX_API_KEY?: unknown;
};
};
};
export function getBootstrapDataFromDocument(): unknown {
if (typeof document === 'undefined') {
return undefined;
}
try {
const appContainer = document.getElementById('app');
const dataBootstrap = appContainer?.getAttribute('data-bootstrap');
return dataBootstrap ? JSON.parse(dataBootstrap) : undefined;
} catch {
return undefined;
}
}
export function getMapboxApiKeyFromBootstrap(
bootstrapData: unknown = getBootstrapDataFromDocument(),
): string {
const mapboxApiKey = (bootstrapData as BootstrapData | undefined)?.common
?.conf?.MAPBOX_API_KEY;
return typeof mapboxApiKey === 'string' ? mapboxApiKey.trim() : '';
}
export function hasMapboxApiKey(
bootstrapData: unknown = getBootstrapDataFromDocument(),
): boolean {
return getMapboxApiKeyFromBootstrap(bootstrapData).trim().length > 0;
}
export function getDefaultMapRenderer(
bootstrapData: unknown = getBootstrapDataFromDocument(),
): MapProvider {
const conf = (bootstrapData as BootstrapData | undefined)?.common?.conf;
const defaultRenderer = conf?.DEFAULT_MAP_RENDERER;
if (defaultRenderer === 'mapbox' && hasMapboxApiKey(bootstrapData)) {
return 'mapbox';
}
return 'maplibre';
}
export function getMapRendererOptions({
hasMapboxKey,
currentValue,
}: {
hasMapboxKey: boolean;
currentValue?: MapProvider;
}): MapRendererOption[] {
if (!hasMapboxKey && currentValue !== 'mapbox') {
return [MAPLIBRE_RENDERER_OPTION];
}
return [
MAPLIBRE_RENDERER_OPTION,
hasMapboxKey ? MAPBOX_RENDERER_OPTION : DISABLED_MAPBOX_RENDERER_OPTION,
];
}
function getNonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0
? value
: undefined;
}
function isMapboxStyle(value: unknown): boolean {
return getNonEmptyString(value)?.startsWith('mapbox://') ?? false;
}
export function getMapProviderMapStyle({
mapProvider,
maplibreStyle,
mapboxStyle,
legacyMapStyle,
}: MapProviderMapStyle): SelectedMapProviderMapStyle {
const selectedMapProvider: MapProvider =
mapProvider === 'mapbox' ? 'mapbox' : 'maplibre';
const maplibreStyleValue = getNonEmptyString(maplibreStyle);
const mapboxStyleValue = getNonEmptyString(mapboxStyle);
const legacyMapStyleValue = getNonEmptyString(legacyMapStyle);
if (selectedMapProvider === 'mapbox') {
return {
mapProvider: selectedMapProvider,
mapStyle: mapboxStyleValue ?? legacyMapStyleValue,
};
}
return {
mapProvider: selectedMapProvider,
mapStyle:
maplibreStyleValue ??
(isMapboxStyle(mapboxStyleValue) ? undefined : mapboxStyleValue) ??
legacyMapStyleValue,
};
}
function unwrapTileProtocol(value: string): string {
return value.startsWith(TILE_PROTOCOL)
? value.slice(TILE_PROTOCOL.length)
: value;
}
export function isRasterTileTemplate(value: unknown): value is string {
if (typeof value !== 'string') {
return false;
}
const tileUrl = unwrapTileProtocol(value);
return ['{z}', '{x}', '{y}'].every(templateParam =>
tileUrl.includes(templateParam),
);
}
function isOpenStreetMapTileUrl(value: string): boolean {
try {
const hostname = new URL(value).hostname.toLowerCase();
return (
hostname === 'openstreetmap.org' ||
hostname.endsWith('.openstreetmap.org')
);
} catch {
return false;
}
}
export function buildRasterTileMapStyle(value: string): RasterTileMapStyle {
const tileUrl = unwrapTileProtocol(value);
const attribution = isOpenStreetMapTileUrl(tileUrl)
? { attribution: OSM_TILE_ATTRIBUTION }
: {};
return {
version: 8,
sources: {
[RASTER_SOURCE_ID]: {
type: 'raster',
tiles: [tileUrl],
tileSize: 256,
...attribution,
},
},
layers: [
{
id: RASTER_LAYER_ID,
type: 'raster',
source: RASTER_SOURCE_ID,
minzoom: 0,
maxzoom: 22,
},
],
};
}
export function resolveMapStyle(
value: string | undefined,
defaultStyle: string,
): ResolvedMapStyle {
if (!value) {
return defaultStyle;
}
return isRasterTileTemplate(value) ? buildRasterTileMapStyle(value) : value;
}

View File

@@ -19,9 +19,9 @@
import '@testing-library/jest-dom';
import PropTypes from 'prop-types';
import { useEffect, useState } from 'react';
import { PureComponent } from 'react';
import { reactify } from '@superset-ui/core';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { RenderFuncType } from '../../../src/chart/components/reactify';
describe('reactify(renderFn)', () => {
@@ -52,41 +52,48 @@ describe('reactify(renderFn)', () => {
componentWillUnmount: willUnmountCb,
});
function TestComponent() {
const [content, setContent] = useState('abc');
class TestComponent extends PureComponent<{}, { content: string }> {
constructor(props = {}) {
super(props);
this.state = { content: 'abc' };
}
useEffect(() => {
const timer = setTimeout(() => {
setContent('def');
componentDidMount() {
setTimeout(() => {
this.setState({ content: 'def' });
}, 10);
return () => clearTimeout(timer);
}, []);
}
return <TheChart id="test" content={content} />;
render() {
const { content } = this.state;
return <TheChart id="test" content={content} />;
}
}
function AnotherTestComponent() {
return <TheChartWithWillUnmountHook id="another_test" />;
class AnotherTestComponent extends PureComponent<{}, {}> {
render() {
return <TheChartWithWillUnmountHook id="another_test" />;
}
}
beforeEach(() => {
(renderFn as jest.Mock).mockClear();
willUnmountCb.mockClear();
});
test('returns a React component class', () =>
new Promise(done => {
render(<TestComponent />);
test('returns a React component and re-renders on prop changes', async () => {
render(<TestComponent />);
expect(renderFn).toHaveBeenCalledTimes(1);
expect(screen.getByText('abc')).toBeInTheDocument();
expect(screen.getByText('abc').parentNode).toHaveAttribute('id', 'test');
await waitFor(() => {
expect(screen.getByText('def')).toBeInTheDocument();
});
expect(screen.getByText('def').parentNode).toHaveAttribute('id', 'test');
expect(renderFn).toHaveBeenCalledTimes(2);
});
expect(renderFn).toHaveBeenCalledTimes(1);
expect(screen.getByText('abc')).toBeInTheDocument();
expect(screen.getByText('abc').parentNode).toHaveAttribute('id', 'test');
setTimeout(() => {
expect(renderFn).toHaveBeenCalledTimes(2);
expect(screen.getByText('def')).toBeInTheDocument();
expect(screen.getByText('def').parentNode).toHaveAttribute(
'id',
'test',
);
done(undefined);
}, 20);
}));
describe('displayName', () => {
test('has displayName if renderFn.displayName is defined', () => {
expect(TheChart.displayName).toEqual('BoldText');
@@ -119,16 +126,20 @@ describe('reactify(renderFn)', () => {
expect(AnotherChart.defaultProps).toBeUndefined();
});
});
test('calls renderFn when container is set', () => {
test('does not try to render if not mounted', () => {
const anotherRenderFn = jest.fn();
const AnotherChart = reactify(anotherRenderFn);
const { unmount } = render(<AnotherChart id="test" />);
expect(anotherRenderFn).toHaveBeenCalled();
unmount();
});
test('calls willUnmount hook when it is provided', () => {
const { unmount } = render(<AnotherTestComponent />);
unmount();
expect(willUnmountCb).toHaveBeenCalledTimes(1);
const AnotherChart = reactify(anotherRenderFn); // enables valid new AnotherChart() call
// @ts-expect-error
new AnotherChart({ id: 'test' }).execute();
expect(anotherRenderFn).not.toHaveBeenCalled();
});
test('calls willUnmount hook when it is provided', () =>
new Promise(done => {
const { unmount } = render(<AnotherTestComponent />);
setTimeout(() => {
unmount();
expect(willUnmountCb).toHaveBeenCalledTimes(1);
done(undefined);
}, 20);
}));
});

View File

@@ -35,7 +35,7 @@ test('format milliseconds in human readable format with default options', () =>
});
test('format seconds in human readable format with default options', () => {
const formatter = createDurationFormatter({ multiplier: 1000 });
expect(formatter(-0.5)).toBe('0s');
expect(formatter(-0.5)).toBe('-0s');
expect(formatter(0.5)).toBe('0s');
expect(formatter(1)).toBe('1s');
expect(formatter(30)).toBe('30s');

View File

@@ -34,7 +34,7 @@
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.18.1",
"nvd3-fork": "^2.0.5",
"dompurify": "^3.4.8",
"dompurify": "^3.4.7",
"prop-types": "^15.8.1",
"urijs": "^1.19.11"
},

View File

@@ -126,7 +126,7 @@ export default function transformProps(
...DEFAULT_RADAR_FORM_DATA,
...formData,
};
const { setDataMask = () => {}, onContextMenu } = hooks ?? {};
const { setDataMask = () => {}, onContextMenu } = hooks;
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
const numberFormatter = getNumberFormatter(numberFormat);
const denormalizedSeriesValues: SeriesNormalizedMap = {};
@@ -140,7 +140,7 @@ export default function transformProps(
const metricLabels = metrics.map(getMetricLabel);
const metricsWithCustomBounds = new Set<string>(
const metricsWithCustomBounds = new Set(
metricLabels.filter(metricLabel => {
const config = columnConfig?.[metricLabel];
const hasMax = !!isDefined(config?.radarMetricMaxValue);
@@ -358,7 +358,6 @@ export default function transformProps(
metricLabels,
getDenormalizedSeriesValue,
metricsWithCustomBounds,
numberFormatter,
);
const echartOptions: EChartsCoreOption = {

View File

@@ -16,8 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { NumberFormatter } from '@superset-ui/core';
/*
function for finding the max metric values among all series data for Radar Chart
*/
@@ -49,7 +47,7 @@ interface TooltipParams {
interface TooltipMetricValue {
metric: string;
value: number | string;
value: number;
}
export const renderNormalizedTooltip = (
@@ -57,7 +55,6 @@ export const renderNormalizedTooltip = (
metrics: string[],
getDenormalizedValue: (seriesName: string, value: string) => number,
metricsWithCustomBounds: Set<string>,
formatter?: NumberFormatter,
): string => {
const { color, name = '', value: values } = params;
const seriesName = name || 'series0';
@@ -73,7 +70,7 @@ export const renderNormalizedTooltip = (
return {
metric,
value: formatter ? formatter(originalValue) : originalValue,
value: originalValue,
};
});

View File

@@ -1,55 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { getNumberFormatter } from '@superset-ui/core';
import { renderNormalizedTooltip } from '../../src/Radar/utils';
describe('renderNormalizedTooltip', () => {
const mockGetDenormalizedValue = jest.fn((_, value) => Number(value));
const metrics = ['metric1', 'metric2'];
const params = {
color: 'red',
name: 'series1',
value: [100, 200],
};
const metricsWithCustomBounds = new Set<string>();
test('should render tooltip with formatted values when formatter is provided', () => {
const formatter = getNumberFormatter(',.2f');
const tooltip = renderNormalizedTooltip(
params,
metrics,
mockGetDenormalizedValue,
metricsWithCustomBounds,
formatter,
);
expect(tooltip).toContain(formatter(100));
expect(tooltip).toContain(formatter(200));
});
test('should render tooltip with raw values when formatter is not provided', () => {
const tooltip = renderNormalizedTooltip(
params,
metrics,
mockGetDenormalizedValue,
metricsWithCustomBounds,
);
expect(tooltip).toContain('100');
expect(tooltip).toContain('200');
});
});

View File

@@ -20,10 +20,6 @@ import { memo, useCallback, useEffect, useState } from 'react';
import { Map as MapLibreMap } from 'react-map-gl/maplibre';
import { Map as MapboxMap } from 'react-map-gl/mapbox';
import { WebMercatorViewport } from '@math.gl/web-mercator';
import {
resolveMapStyle,
type ResolvedMapStyle,
} from '@superset-ui/core/utils/mapStyles';
import { useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import ScatterPlotOverlay from './components/ScatterPlotOverlay';
@@ -164,10 +160,7 @@ function MapLibre({
const clusters = clusterer.getClusters(bbox, Math.round(viewport.zoom));
const theme = useTheme();
const resolvedMapStyle: ResolvedMapStyle =
mapProvider === 'mapbox'
? mapStyle || DEFAULT_MAP_STYLE
: resolveMapStyle(mapStyle, DEFAULT_MAP_STYLE);
const resolvedMapStyle = mapStyle || DEFAULT_MAP_STYLE;
const mapboxApiKey = mapProvider === 'mapbox' ? getMapboxApiKey() : '';
if (mapProvider === 'mapbox' && !mapboxApiKey) {

View File

@@ -19,19 +19,11 @@
import { t } from '@apache-superset/core/translation';
import {
columnChoices,
ControlPanelState,
ControlPanelConfig,
formatSelectOptions,
sharedControls,
getStandardizedControls,
} from '@superset-ui/chart-controls';
import type { QueryFormData } from '@superset-ui/core';
import type { MapProvider } from '@superset-ui/core/utils/mapStyles';
import { getDefaultMapRenderer } from '@superset-ui/core/utils/mapStyles';
import {
getPointClusterMapRendererProps,
POINT_CLUSTER_MAPLIBRE_STYLE_CHOICES,
} from './utils/mapControls';
const columnsConfig = sharedControls.entity;
@@ -43,11 +35,6 @@ const colorChoices = [
['#dc143c', t('Crimson')],
['#228b22', t('Forest Green')],
];
type MapStyleVisibilityProps = {
controls?: {
map_renderer?: { value?: unknown };
};
};
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -122,7 +109,7 @@ const config: ControlPanelConfig = {
'Either a numerical column or `Auto`, which scales the point based ' +
'on the largest cluster',
),
mapStateToProps: (state: ControlPanelState) => {
mapStateToProps: (state: any) => {
const datasourceChoices = columnChoices(state.datasource);
const choices: [string, string][] = [['Auto', t('Auto')]];
return {
@@ -169,7 +156,7 @@ const config: ControlPanelConfig = {
'Non-numerical columns will be used to label points. ' +
'Leave empty to get a count of points in each cluster.',
),
mapStateToProps: (state: ControlPanelState) => ({
mapStateToProps: (state: any) => ({
choices: columnChoices(state.datasource),
}),
},
@@ -213,17 +200,14 @@ const config: ControlPanelConfig = {
label: t('Map Renderer'),
clearable: false,
renderTrigger: true,
options: getPointClusterMapRendererProps().options,
choices: [
['maplibre', t('MapLibre (open-source)')],
['mapbox', t('Mapbox (API key required)')],
],
default: 'maplibre',
description: t(
'MapLibre is open-source and requires no API key. Mapbox requires MAPBOX_API_KEY to be configured on the server.',
),
mapStateToProps: (state: ControlPanelState) => ({
...getPointClusterMapRendererProps(
state.form_data?.map_renderer as MapProvider | undefined,
),
default: getDefaultMapRenderer(),
}),
},
},
],
@@ -236,13 +220,30 @@ const config: ControlPanelConfig = {
clearable: false,
renderTrigger: true,
freeForm: true,
choices: POINT_CLUSTER_MAPLIBRE_STYLE_CHOICES,
choices: [
[
'https://tiles.openfreemap.org/styles/liberty',
t('Liberty (OpenFreeMap)'),
],
[
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
t('Light (Carto)'),
],
[
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
t('Dark (Carto)'),
],
[
'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
t('Streets (Carto)'),
],
],
default: 'https://tiles.openfreemap.org/styles/liberty',
description: t(
'Base layer map style. See MapLibre documentation: %s',
'https://maplibre.org/maplibre-style-spec/',
),
visibility: ({ controls }: MapStyleVisibilityProps) =>
visibility: ({ controls }: any) =>
controls?.map_renderer?.value !== 'mapbox',
},
},
@@ -271,7 +272,7 @@ const config: ControlPanelConfig = {
description: t(
'Base layer map style. Accepts a Mapbox style URL (mapbox://styles/...).',
),
visibility: ({ controls }: MapStyleVisibilityProps) =>
visibility: ({ controls }: any) =>
controls?.map_renderer?.value === 'mapbox',
},
},
@@ -386,7 +387,7 @@ const config: ControlPanelConfig = {
),
},
},
formDataOverrides: (formData: QueryFormData) => ({
formDataOverrides: (formData: any) => ({
...formData,
groupby: getStandardizedControls().popAllColumns(),
}),

View File

@@ -19,7 +19,7 @@
import Supercluster, {
type Options as SuperclusterOptions,
} from 'supercluster';
import { ChartProps, getMapProviderMapStyle } from '@superset-ui/core';
import { ChartProps } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { DEFAULT_POINT_RADIUS, DEFAULT_MAX_ZOOM } from './MapLibre';
import roundDecimal from './utils/roundDecimal';
@@ -152,7 +152,6 @@ export default function transformProps(chartProps: ChartProps) {
map_renderer: mapProvider,
maplibre_style: maplibreStyle,
mapbox_style: mapboxStyle = '',
map_style: legacyMapStyle,
pandas_aggfunc: pandasAggfunc,
point_radius: pointRadius,
point_radius_unit: pointRadiusUnit,
@@ -243,12 +242,6 @@ export default function transformProps(chartProps: ChartProps) {
const clusterer = new Supercluster<PointProperties, ClusterProperties>(opts);
// Disable strict typecheck on load since Supercluster typings have namespace issues with esModuleInterop
clusterer.load(geoJSON.features as any);
const selectedMap = getMapProviderMapStyle({
mapProvider,
maplibreStyle,
mapboxStyle,
legacyMapStyle,
});
return {
width,
@@ -258,8 +251,11 @@ export default function transformProps(chartProps: ChartProps) {
clusterer,
globalOpacity: Math.min(1, Math.max(0, toFiniteNumber(globalOpacity) ?? 1)),
hasCustomMetric,
mapProvider: selectedMap.mapProvider,
mapStyle: selectedMap.mapStyle,
mapProvider,
mapStyle:
mapProvider === 'mapbox'
? (mapboxStyle as string)
: (maplibreStyle as string),
onViewportChange({
latitude,
longitude,

View File

@@ -1,60 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import {
getMapRendererOptions,
OSM_TILE_STYLE_URL,
type MapRendererOption,
type MapProvider,
} from '@superset-ui/core/utils/mapStyles';
import { hasMapboxApiKey } from './mapbox';
export const POINT_CLUSTER_MAPLIBRE_STYLE_CHOICES = [
['https://tiles.openfreemap.org/styles/liberty', t('Liberty (OpenFreeMap)')],
[
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
t('Light (Carto)'),
],
[
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
t('Dark (Carto)'),
],
[
'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
t('Streets (Carto)'),
],
[OSM_TILE_STYLE_URL, t('Streets (OSM)')],
];
export function getPointClusterMapRendererProps(currentValue?: MapProvider) {
const hasKey = hasMapboxApiKey();
return {
options: getMapRendererOptions({
hasMapboxKey: hasKey,
currentValue,
}).map((option: MapRendererOption) => ({
...option,
label:
option.value === 'maplibre'
? t('MapLibre (open-source)')
: t('Mapbox (API key required)'),
})),
};
}

View File

@@ -17,15 +17,19 @@
* under the License.
*/
import {
getMapboxApiKeyFromBootstrap,
hasMapboxApiKey as hasBootstrapMapboxApiKey,
} from '@superset-ui/core/utils/mapStyles';
export function getMapboxApiKey(): string {
return getMapboxApiKeyFromBootstrap();
}
export function hasMapboxApiKey(): boolean {
return hasBootstrapMapboxApiKey();
if (typeof document === 'undefined') {
return '';
}
try {
const appContainer = document.getElementById('app');
const dataBootstrap = appContainer?.getAttribute('data-bootstrap');
if (dataBootstrap) {
const bootstrapData = JSON.parse(dataBootstrap);
return bootstrapData?.common?.conf?.MAPBOX_API_KEY || '';
}
} catch {
// If bootstrap data is unavailable or malformed, return empty string
}
return '';
}

View File

@@ -18,12 +18,7 @@
*/
import { type ReactNode } from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import {
OSM_TILE_ATTRIBUTION,
OSM_TILE_STYLE_URL,
} from '@superset-ui/core/utils/mapStyles';
import { render } from '@testing-library/react';
// Capture the most recent viewport props passed to the Map component
let lastMapProps: Record<string, unknown> = {};
@@ -96,7 +91,6 @@ const defaultProps = {
beforeEach(() => {
lastMapProps = {};
document.body.innerHTML = '';
jest.clearAllMocks();
mockFitBounds.mockImplementation(
(
@@ -189,65 +183,6 @@ test('passes globalOpacity to ScatterPlotOverlay', () => {
expect(overlay!.getAttribute('data-opacity')).toBe('0.5');
});
test('converts OSM raster tile templates into MapLibre style objects', () => {
render(<MapLibre {...defaultProps} mapStyle={OSM_TILE_STYLE_URL} />);
expect(lastMapProps.mapStyle).toEqual({
version: 8,
sources: {
'osm-raster-tiles': {
type: 'raster',
tiles: [OSM_TILE_STYLE_URL],
tileSize: 256,
attribution: OSM_TILE_ATTRIBUTION,
},
},
layers: [
{
id: 'osm-raster-layer',
type: 'raster',
source: 'osm-raster-tiles',
minzoom: 0,
maxzoom: 22,
},
],
});
});
test('keeps the missing Mapbox key signal for saved Mapbox charts', () => {
render(
<MapLibre
{...defaultProps}
mapProvider="mapbox"
mapStyle="mapbox://styles/mapbox/dark-v11"
/>,
);
expect(
screen.getByText(
'Mapbox requires a MAPBOX_API_KEY to be configured on the server.',
),
).toBeInTheDocument();
expect(lastMapProps.mapStyle).toBeUndefined();
});
test('passes Mapbox styles through when a key exists', () => {
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
common: { conf: { MAPBOX_API_KEY: 'pk.test' } },
})}'></div>`;
render(
<MapLibre
{...defaultProps}
mapProvider="mapbox"
mapStyle="mapbox://styles/mapbox/dark-v11"
/>,
);
expect(lastMapProps.mapStyle).toBe('mapbox://styles/mapbox/dark-v11');
expect(lastMapProps.mapboxAccessToken).toBe('pk.test');
});
test('handles undefined bounds gracefully', () => {
render(<MapLibre {...defaultProps} bounds={undefined} />);
expect(lastMapProps.longitude).toBe(0);

View File

@@ -17,11 +17,9 @@
* under the License.
*/
import type {
ControlPanelState,
ControlPanelConfig,
CustomControlItem,
} from '@superset-ui/chart-controls';
import { OSM_TILE_STYLE_URL } from '@superset-ui/core/utils/mapStyles';
import controlPanel from '../src/controlPanel';
type ControlConfig = Required<CustomControlItem['config']>;
@@ -56,27 +54,6 @@ function getControl(
return item;
}
type RendererControlConfig = ControlConfig & {
mapStateToProps: (state: ControlPanelState) => {
options?: unknown;
warning?: string;
default?: unknown;
};
};
const setBootstrap = (conf: Record<string, unknown>) => {
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
common: { conf },
})}'></div>`;
};
const getMapRendererProps = (value?: string) =>
(
getControl(controlPanel, 'map_renderer').config as RendererControlConfig
).mapStateToProps({
form_data: { map_renderer: value },
} as unknown as ControlPanelState);
test('viewport controls default to empty values and rerender without query refresh', () => {
const longitudeControl = getControl(controlPanel, 'viewport_longitude');
const latitudeControl = getControl(controlPanel, 'viewport_latitude');
@@ -102,63 +79,3 @@ test('opacity control rerenders immediately when changed', () => {
expect(opacityControl.config.renderTrigger).toBe(true);
expect(opacityControl.config.isFloat).toBe(true);
});
test('MapLibre style choices expose Streets (OSM)', () => {
expect(
getControl(controlPanel, 'maplibre_style').config.choices,
).toContainEqual([OSM_TILE_STYLE_URL, 'Streets (OSM)']);
});
test('map renderer hides Mapbox when no key exists for new selections', () => {
setBootstrap({});
const props = getMapRendererProps('maplibre');
expect(props.options).toEqual([
{ value: 'maplibre', label: 'MapLibre (open-source)' },
]);
});
test('map renderer keeps saved Mapbox visible while disabled without a key', () => {
setBootstrap({});
const props = getMapRendererProps('mapbox');
expect(props.options).toContainEqual({
value: 'mapbox',
label: 'Mapbox (API key required)',
disabled: true,
});
});
test('map renderer enables Mapbox when a key exists', () => {
setBootstrap({ MAPBOX_API_KEY: 'pk.test' });
const props = getMapRendererProps('maplibre');
expect(props.options).toEqual([
{ value: 'maplibre', label: 'MapLibre (open-source)' },
{ value: 'mapbox', label: 'Mapbox (API key required)' },
]);
});
test('map renderer keeps the original explanatory description', () => {
expect(getControl(controlPanel, 'map_renderer').config.description).toBe(
'MapLibre is open-source and requires no API key. Mapbox requires MAPBOX_API_KEY to be configured on the server.',
);
});
test('map renderer defaults to configured Mapbox when a key exists', () => {
setBootstrap({
DEFAULT_MAP_RENDERER: 'mapbox',
MAPBOX_API_KEY: 'pk.test',
});
expect(getMapRendererProps('maplibre').default).toBe('mapbox');
});
test('map renderer falls back from configured Mapbox default without a key', () => {
setBootstrap({ DEFAULT_MAP_RENDERER: 'mapbox' });
expect(getMapRendererProps('maplibre').default).toBe('maplibre');
});

View File

@@ -34,8 +34,6 @@ import transformProps from '../src/transformProps';
type TransformPropsResult = {
globalOpacity?: number;
mapProvider?: string;
mapStyle?: string;
onViewportChange?: (viewport: {
latitude: number;
longitude: number;
@@ -217,41 +215,6 @@ test('passes through numeric values unchanged', () => {
expect(result.globalOpacity).toBe(0.8);
});
test('uses the MapLibre style when maplibre renderer is selected', () => {
const result = getTransformPropsResult({
map_renderer: 'maplibre',
maplibre_style: 'https://example.com/maplibre-style.json',
mapbox_style: 'mapbox://styles/mapbox/dark-v11',
});
expect(result.mapProvider).toBe('maplibre');
expect(result.mapStyle).toBe('https://example.com/maplibre-style.json');
});
test('uses legacy non-Mapbox style for MapLibre when provider style is absent', () => {
const result = getTransformPropsResult({
map_renderer: 'maplibre',
maplibre_style: undefined,
mapbox_style: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
});
expect(result.mapProvider).toBe('maplibre');
expect(result.mapStyle).toBe(
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
);
});
test('uses the Mapbox style when mapbox renderer is selected', () => {
const result = getTransformPropsResult({
map_renderer: 'mapbox',
maplibre_style: 'https://example.com/maplibre-style.json',
mapbox_style: 'mapbox://styles/mapbox/dark-v11',
});
expect(result.mapProvider).toBe('mapbox');
expect(result.mapStyle).toBe('mapbox://styles/mapbox/dark-v11');
});
test('calls onError and falls back to black for invalid color', () => {
const onError = jest.fn();
const chartProps = new ChartProps({

View File

@@ -29,7 +29,7 @@
"@deck.gl/extensions": "~9.2.9",
"@deck.gl/geo-layers": "~9.2.5",
"@deck.gl/layers": "~9.2.5",
"@deck.gl/mapbox": "~9.3.3",
"@deck.gl/mapbox": "~9.3.2",
"@deck.gl/mesh-layers": "~9.2.5",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",

View File

@@ -34,7 +34,6 @@ import {
JsonValue,
QueryFormData,
SetDataMaskHook,
getMapProviderMapStyle,
} from '@superset-ui/core';
import type { Layer } from '@deck.gl/core';
import Legend from './components/Legend';
@@ -319,12 +318,6 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
},
[categories],
);
const selectedMap = getMapProviderMapStyle({
mapProvider: props.formData.map_renderer,
maplibreStyle: props.formData.maplibre_style,
mapboxStyle: props.formData.mapbox_style,
legacyMapStyle: props.formData.map_style,
});
return (
<div style={{ position: 'relative' }}>
@@ -333,8 +326,14 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
viewport={viewport}
layers={getLayers()}
setControlValue={props.setControlValue}
mapStyle={selectedMap.mapStyle}
mapProvider={selectedMap.mapProvider}
mapStyle={
props.formData.map_renderer === 'mapbox'
? props.formData.mapbox_style
: props.formData.maplibre_style
}
mapProvider={
props.formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'
}
mapboxApiKey={getMapboxApiKey()}
width={props.width}
height={props.height}

View File

@@ -1,240 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ComponentProps, createRef, ReactNode } from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import type { Layer } from '@deck.gl/core';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
import {
OSM_TILE_ATTRIBUTION,
OSM_TILE_STYLE_URL,
} from '@superset-ui/core/utils/mapStyles';
import mapboxgl from 'mapbox-gl';
import { DeckGLContainer, DeckGLContainerHandle } from './DeckGLContainer';
jest.mock('react-map-gl/maplibre', () => ({
Map: ({
children,
mapStyle,
onMove,
}: {
children: ReactNode;
mapStyle: unknown;
onMove: (evt: { viewState: Record<string, number> }) => void;
}) => (
<div data-test="maplibre-map" data-map-style={JSON.stringify(mapStyle)}>
<button
type="button"
data-test="maplibre-move"
onClick={() =>
onMove({ viewState: { longitude: 1, latitude: 2, zoom: 3 } })
}
/>
{children}
</div>
),
}));
jest.mock('react-map-gl/mapbox', () => ({
Map: ({ children, mapStyle }: { children: ReactNode; mapStyle: unknown }) => (
<div data-test="mapbox-map" data-map-style={JSON.stringify(mapStyle)}>
{children}
</div>
),
}));
jest.mock('mapbox-gl', () => ({ accessToken: '' }));
jest.mock(
'./components/DeckGLOverlayMapLibre',
() =>
({ layers }: { layers: unknown[] }) => (
<div data-test="maplibre-overlay" data-layers-count={layers.length} />
),
);
jest.mock(
'./components/DeckGLOverlayMapbox',
() =>
({ layers }: { layers: unknown[] }) => (
<div data-test="mapbox-overlay" data-layers-count={layers.length} />
),
);
jest.mock('./components/Tooltip', () => ({
__esModule: true,
default: ({ variant = 'default' }: { variant?: 'default' | 'custom' }) => (
<div data-test={`tooltip-${variant}`} />
),
}));
const baseProps = {
viewport: { longitude: 0, latitude: 0, zoom: 1, bearing: 0, pitch: 0 },
width: 800,
height: 600,
layers: [],
};
const renderContainer = (
props: Partial<ComponentProps<typeof DeckGLContainer>>,
) =>
render(
<ThemeProvider theme={supersetTheme}>
<DeckGLContainer {...baseProps} {...props} />
</ThemeProvider>,
);
afterEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
});
test('DeckGLContainer converts OSM raster tile templates into MapLibre style objects', () => {
renderContainer({ mapProvider: 'maplibre', mapStyle: OSM_TILE_STYLE_URL });
const style = JSON.parse(
screen.getByTestId('maplibre-map').getAttribute('data-map-style') || '{}',
);
expect(style.sources['osm-raster-tiles']).toEqual({
type: 'raster',
tiles: [OSM_TILE_STYLE_URL],
tileSize: 256,
attribution: OSM_TILE_ATTRIBUTION,
});
expect(style.layers[0]).toMatchObject({
id: 'osm-raster-layer',
type: 'raster',
source: 'osm-raster-tiles',
});
});
test('DeckGLContainer passes style JSON URLs through to MapLibre', () => {
const styleUrl = 'https://example.com/styles/custom-style.json';
renderContainer({ mapProvider: 'maplibre', mapStyle: styleUrl });
expect(screen.getByTestId('maplibre-map')).toHaveAttribute(
'data-map-style',
JSON.stringify(styleUrl),
);
});
test('DeckGLContainer keeps the missing Mapbox key signal for saved Mapbox charts', () => {
renderContainer({
mapProvider: 'mapbox',
mapStyle: 'mapbox://styles/mapbox/dark-v9',
mapboxApiKey: '',
});
expect(
screen.getByText(
'Mapbox requires a MAPBOX_API_KEY to be configured on the server.',
),
).toBeInTheDocument();
expect(screen.queryByTestId('maplibre-map')).not.toBeInTheDocument();
expect(screen.queryByTestId('mapbox-map')).not.toBeInTheDocument();
});
test('DeckGLContainer passes Mapbox styles through when a key exists', () => {
renderContainer({
mapProvider: 'mapbox',
mapStyle: 'mapbox://styles/mapbox/dark-v9',
mapboxApiKey: 'pk.test',
});
expect(mapboxgl.accessToken).toBe('pk.test');
expect(screen.getByTestId('mapbox-map')).toHaveAttribute(
'data-map-style',
JSON.stringify('mapbox://styles/mapbox/dark-v9'),
);
});
test('DeckGLContainer supports layer factories for MapLibre overlays', () => {
const layer = { id: 'layer-1' } as unknown as Layer;
const layerFactory = () => layer;
renderContainer({ mapProvider: 'maplibre', layers: [layerFactory] });
expect(screen.getByTestId('maplibre-overlay')).toHaveAttribute(
'data-layers-count',
'1',
);
});
test('DeckGLContainer updates viewport controls after map movement is throttled', () => {
jest.useFakeTimers();
jest.setSystemTime(1000);
const setControlValue = jest.fn();
renderContainer({ mapProvider: 'maplibre', setControlValue });
fireEvent.click(screen.getByTestId('maplibre-move'));
jest.setSystemTime(1301);
act(() => {
jest.advanceTimersByTime(250);
});
expect(setControlValue).toHaveBeenCalledWith('viewport', {
longitude: 1,
latitude: 2,
zoom: 3,
});
});
test('DeckGLContainer suppresses the native context menu', () => {
renderContainer({ mapProvider: 'maplibre' });
const event = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
});
const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
const stopPropagationSpy = jest.spyOn(event, 'stopPropagation');
screen.getByTestId('maplibre-map').parentElement?.dispatchEvent(event);
expect(preventDefaultSpy).toHaveBeenCalled();
expect(stopPropagationSpy).toHaveBeenCalled();
});
test('DeckGLContainer renders default and custom tooltip variants through its ref', () => {
const ref = createRef<DeckGLContainerHandle>();
render(
<ThemeProvider theme={supersetTheme}>
<DeckGLContainer {...baseProps} mapProvider="maplibre" ref={ref} />
</ThemeProvider>,
);
act(() => {
ref.current?.setTooltip({ x: 0, y: 0, content: 'Default tooltip' });
});
expect(screen.getByTestId('tooltip-default')).toBeInTheDocument();
act(() => {
ref.current?.setTooltip({
x: 0,
y: 0,
content: <span data-tooltip-type="custom">Custom tooltip</span>,
});
});
expect(screen.getByTestId('tooltip-custom')).toBeInTheDocument();
});

View File

@@ -33,11 +33,6 @@ import { Map as MapboxMap } from 'react-map-gl/mapbox';
import mapboxgl from 'mapbox-gl';
import type { Layer } from '@deck.gl/core';
import { JsonObject, JsonValue, usePrevious } from '@superset-ui/core';
import {
resolveMapStyle,
type MapProvider,
type ResolvedMapStyle,
} from '@superset-ui/core/utils/mapStyles';
import { styled, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import DeckGLOverlayMapLibre from './components/DeckGLOverlayMapLibre';
@@ -55,7 +50,7 @@ export type DeckGLContainerProps = {
viewport: Viewport;
setControlValue?: (control: string, value: JsonValue) => void;
mapStyle?: string;
mapProvider?: MapProvider;
mapProvider?: 'maplibre' | 'mapbox';
mapboxApiKey?: string;
children?: ReactNode;
width: number;
@@ -128,9 +123,7 @@ export const DeckGLContainer = memo(
const theme = useTheme();
const { children = null, height, width } = props;
const isMapbox = props.mapProvider === 'mapbox';
const mapStyle: ResolvedMapStyle = isMapbox
? props.mapStyle || DEFAULT_MAP_STYLE
: resolveMapStyle(props.mapStyle, DEFAULT_MAP_STYLE);
const mapStyle = props.mapStyle || DEFAULT_MAP_STYLE;
if (isMapbox && !props.mapboxApiKey) {
return (

View File

@@ -38,7 +38,6 @@ import {
QueryFormData,
QueryObjectFilterClause,
SupersetClient,
getMapProviderMapStyle,
usePrevious,
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
@@ -398,12 +397,6 @@ const DeckMulti = (props: DeckMultiProps) => {
.filter(layer => layer !== undefined),
[layerOrder, subSlicesLayers],
);
const selectedMap = getMapProviderMapStyle({
mapProvider: formData.map_renderer,
maplibreStyle: formData.maplibre_style,
mapboxStyle: formData.mapbox_style,
legacyMapStyle: formData.map_style,
});
return (
<MultiWrapper height={height} width={width}>
@@ -411,8 +404,12 @@ const DeckMulti = (props: DeckMultiProps) => {
ref={containerRef}
viewport={viewport}
layers={layers}
mapStyle={selectedMap.mapStyle}
mapProvider={selectedMap.mapProvider}
mapStyle={
formData.map_renderer === 'mapbox'
? formData.mapbox_style
: formData.maplibre_style
}
mapProvider={formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'}
mapboxApiKey={getMapboxApiKey()}
setControlValue={setControlValue}
onViewportChange={setViewport}

View File

@@ -31,7 +31,6 @@ import {
FilterState,
JsonValue,
ContextMenuFilters,
getMapProviderMapStyle,
} from '@superset-ui/core';
import {
@@ -185,12 +184,6 @@ export function createDeckGLComponent(
}, [computeLayers, prevFormData, prevFilterState, prevPayload, props]);
const { formData, setControlValue, height, width } = props;
const selectedMap = getMapProviderMapStyle({
mapProvider: formData.map_renderer,
maplibreStyle: formData.maplibre_style,
mapboxStyle: formData.mapbox_style,
legacyMapStyle: formData.map_style,
});
return (
<div style={{ position: 'relative' }}>
@@ -198,8 +191,14 @@ export function createDeckGLComponent(
ref={containerRef}
viewport={viewport}
layers={layers}
mapStyle={selectedMap.mapStyle}
mapProvider={selectedMap.mapProvider}
mapStyle={
formData.map_renderer === 'mapbox'
? formData.mapbox_style
: formData.maplibre_style
}
mapProvider={
formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'
}
mapboxApiKey={getMapboxApiKey()}
setControlValue={setControlValue}
width={width}

View File

@@ -0,0 +1,122 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SqlaFormData } from '@superset-ui/core';
import {
computeGeoJsonTextOptionsFromJsOutput,
computeGeoJsonTextOptionsFromFormData,
computeGeoJsonIconOptionsFromJsOutput,
computeGeoJsonIconOptionsFromFormData,
} from './Geojson';
jest.mock('react-map-gl/maplibre', () => ({
__esModule: true,
Map: () => null,
useControl: () => null,
}));
test('computeGeoJsonTextOptionsFromJsOutput returns an empty object for non-object input', () => {
expect(computeGeoJsonTextOptionsFromJsOutput(null)).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput(42)).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput([1, 2, 3])).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput('string')).toEqual({});
});
test('computeGeoJsonTextOptionsFromJsOutput extracts valid text options from the input object', () => {
const input = {
getText: 'name',
getTextColor: [1, 2, 3, 255],
invalidOption: true,
};
const expectedOutput = {
getText: 'name',
getTextColor: [1, 2, 3, 255],
};
expect(computeGeoJsonTextOptionsFromJsOutput(input)).toEqual(expectedOutput);
});
test('computeGeoJsonTextOptionsFromFormData computes text options based on form data', () => {
const formData: SqlaFormData = {
label_property_name: 'name',
label_color: { r: 1, g: 2, b: 3, a: 1 },
label_size: 123,
label_size_unit: 'pixels',
datasource: 'test_datasource',
viz_type: 'deck_geojson',
};
const expectedOutput = {
getText: expect.any(Function),
getTextColor: [1, 2, 3, 255],
getTextSize: 123,
textSizeUnits: 'pixels',
};
const actualOutput = computeGeoJsonTextOptionsFromFormData(formData);
expect(actualOutput).toEqual(expectedOutput);
const sampleFeature = { properties: { name: 'Test' } };
expect(actualOutput.getText(sampleFeature)).toBe('Test');
});
test('computeGeoJsonIconOptionsFromJsOutput returns an empty object for non-object input', () => {
expect(computeGeoJsonIconOptionsFromJsOutput(null)).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput(42)).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput([1, 2, 3])).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput('string')).toEqual({});
});
test('computeGeoJsonIconOptionsFromJsOutput extracts valid icon options from the input object', () => {
const input = {
getIcon: 'icon_name',
getIconColor: [1, 2, 3, 255],
invalidOption: false,
};
const expectedOutput = {
getIcon: 'icon_name',
getIconColor: [1, 2, 3, 255],
};
expect(computeGeoJsonIconOptionsFromJsOutput(input)).toEqual(expectedOutput);
});
test('computeGeoJsonIconOptionsFromFormData computes icon options based on form data', () => {
const formData: SqlaFormData = {
icon_url: 'https://example.com/icon.png',
icon_size: 123,
icon_size_unit: 'pixels',
datasource: 'test_datasource',
viz_type: 'deck_geojson',
};
const expectedOutput = {
getIcon: expect.any(Function),
getIconSize: 123,
iconSizeUnits: 'pixels',
};
const actualOutput = computeGeoJsonIconOptionsFromFormData(formData);
expect(actualOutput).toEqual(expectedOutput);
expect(actualOutput.getIcon()).toEqual({
url: 'https://example.com/icon.png',
height: 128,
width: 128,
});
});

View File

@@ -1,297 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { ReactElement } from 'react';
import type { ControlPanelSectionConfig } from '@superset-ui/chart-controls';
// eslint-disable-next-line import/no-extraneous-dependencies
import { render } from '@testing-library/react';
import { SqlaFormData } from '@superset-ui/core';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
import DeckGLGeoJson, {
computeGeoJsonTextOptionsFromJsOutput,
computeGeoJsonTextOptionsFromFormData,
computeGeoJsonIconOptionsFromJsOutput,
computeGeoJsonIconOptionsFromFormData,
getPoints,
} from './Geojson';
import controlPanel from './controlPanel';
const mockDeckGLContainerProps: Array<Record<string, unknown>> = [];
jest.mock('../../DeckGLContainer', () => ({
DeckGLContainerStyledWrapper: (props: Record<string, unknown>) => {
mockDeckGLContainerProps.push(props);
const React = jest.requireActual('react');
return React.createElement(
'div',
{ 'data-testid': 'deckgl-container' },
props.children,
);
},
}));
jest.mock('../../utils/mapbox', () => ({
getMapboxApiKey: () => 'bootstrap-mapbox-key',
hasMapboxApiKey: () => true,
}));
jest.mock('react-map-gl/maplibre', () => ({
__esModule: true,
Map: () => null,
useControl: () => null,
}));
test('computeGeoJsonTextOptionsFromJsOutput returns an empty object for non-object input', () => {
expect(computeGeoJsonTextOptionsFromJsOutput(null)).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput(42)).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput([1, 2, 3])).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput('string')).toEqual({});
});
test('computeGeoJsonTextOptionsFromJsOutput extracts valid text options from the input object', () => {
const input = {
getText: 'name',
getTextColor: [1, 2, 3, 255],
invalidOption: true,
};
const expectedOutput = {
getText: 'name',
getTextColor: [1, 2, 3, 255],
};
expect(computeGeoJsonTextOptionsFromJsOutput(input)).toEqual(expectedOutput);
});
test('computeGeoJsonTextOptionsFromFormData computes text options based on form data', () => {
const formData: SqlaFormData = {
label_property_name: 'name',
label_color: { r: 1, g: 2, b: 3, a: 1 },
label_size: 123,
label_size_unit: 'pixels',
datasource: 'test_datasource',
viz_type: 'deck_geojson',
};
const expectedOutput = {
getText: expect.any(Function),
getTextColor: [1, 2, 3, 255],
getTextSize: 123,
textSizeUnits: 'pixels',
};
const actualOutput = computeGeoJsonTextOptionsFromFormData(formData);
expect(actualOutput).toEqual(expectedOutput);
const sampleFeature = { properties: { name: 'Test' } };
expect(actualOutput.getText(sampleFeature)).toBe('Test');
});
test('computeGeoJsonIconOptionsFromJsOutput returns an empty object for non-object input', () => {
expect(computeGeoJsonIconOptionsFromJsOutput(null)).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput(42)).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput([1, 2, 3])).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput('string')).toEqual({});
});
test('computeGeoJsonIconOptionsFromJsOutput extracts valid icon options from the input object', () => {
const input = {
getIcon: 'icon_name',
getIconColor: [1, 2, 3, 255],
invalidOption: false,
};
const expectedOutput = {
getIcon: 'icon_name',
getIconColor: [1, 2, 3, 255],
};
expect(computeGeoJsonIconOptionsFromJsOutput(input)).toEqual(expectedOutput);
});
test('computeGeoJsonIconOptionsFromFormData computes icon options based on form data', () => {
const formData: SqlaFormData = {
icon_url: 'https://example.com/icon.png',
icon_size: 123,
icon_size_unit: 'pixels',
datasource: 'test_datasource',
viz_type: 'deck_geojson',
};
const expectedOutput = {
getIcon: expect.any(Function),
getIconSize: 123,
iconSizeUnits: 'pixels',
};
const actualOutput = computeGeoJsonIconOptionsFromFormData(formData);
expect(actualOutput).toEqual(expectedOutput);
expect(actualOutput.getIcon()).toEqual({
url: 'https://example.com/icon.png',
height: 128,
width: 128,
});
});
test('controlPanel expands Map section so renderer controls are visible', () => {
const mapSection = controlPanel.controlPanelSections.find(
(
section: ControlPanelSectionConfig | null,
): section is ControlPanelSectionConfig =>
section !== null && section.label === 'Map',
);
expect(mapSection).toBeDefined();
expect(mapSection?.expanded).toBe(true);
});
test('getPoints skips malformed GeoJSON entries instead of throwing', () => {
const features = [
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [1, 2] },
properties: {},
},
[[0, 0]],
null,
] as unknown as Parameters<typeof getPoints>[0];
expect(getPoints(features)).toEqual([
[1, 2],
[1, 2],
]);
expect(getPoints()).toEqual([]);
});
const renderWithTheme = (component: ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
const geoJsonProps = {
formData: {
datasource: 'test_datasource',
viz_type: 'deck_geojson',
slice_id: 1,
autozoom: false,
map_style: 'legacy-map-style',
extruded: false,
filled: true,
stroked: true,
line_width: 1,
line_width_unit: 'pixels',
point_radius_scale: 1,
enable_labels: false,
enable_icons: false,
},
payload: {
data: {
features: [
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [0, 0] },
properties: { name: 'Test point' },
},
],
},
},
setControlValue: jest.fn(),
viewport: { longitude: 0, latitude: 0, zoom: 1 },
onAddFilter: jest.fn(),
height: 600,
width: 800,
filterState: {},
onContextMenu: jest.fn(),
setDataMask: jest.fn(),
emitCrossFilters: false,
};
const lastDeckGLContainerProps = () =>
mockDeckGLContainerProps
.slice()
.reverse()
.find(props => props?.viewport !== undefined);
test('DeckGLGeoJson passes selected MapLibre renderer props to the container', () => {
mockDeckGLContainerProps.length = 0;
renderWithTheme(
<DeckGLGeoJson
{...geoJsonProps}
formData={{
...geoJsonProps.formData,
map_renderer: 'maplibre',
maplibre_style: 'https://example.com/maplibre-style.json',
mapbox_style: 'mapbox://styles/mapbox/dark-v9',
}}
/>,
);
expect(lastDeckGLContainerProps()).toEqual(
expect.objectContaining({
mapProvider: 'maplibre',
mapStyle: 'https://example.com/maplibre-style.json',
mapboxApiKey: 'bootstrap-mapbox-key',
}),
);
});
test('DeckGLGeoJson passes selected Mapbox renderer props to the container', () => {
mockDeckGLContainerProps.length = 0;
renderWithTheme(
<DeckGLGeoJson
{...geoJsonProps}
formData={{
...geoJsonProps.formData,
map_renderer: 'mapbox',
maplibre_style: 'https://example.com/maplibre-style.json',
mapbox_style: 'mapbox://styles/mapbox/satellite-v9',
}}
/>,
);
expect(lastDeckGLContainerProps()).toEqual(
expect.objectContaining({
mapProvider: 'mapbox',
mapStyle: 'mapbox://styles/mapbox/satellite-v9',
mapboxApiKey: 'bootstrap-mapbox-key',
}),
);
});
test('DeckGLGeoJson falls back to legacy map_style when provider-specific style is absent', () => {
mockDeckGLContainerProps.length = 0;
renderWithTheme(
<DeckGLGeoJson
{...geoJsonProps}
formData={{
...geoJsonProps.formData,
map_renderer: 'maplibre',
maplibre_style: undefined,
map_style: 'legacy-map-style',
}}
/>,
);
expect(lastDeckGLContainerProps()).toEqual(
expect.objectContaining({
mapProvider: 'maplibre',
mapStyle: 'legacy-map-style',
mapboxApiKey: 'bootstrap-mapbox-key',
}),
);
});

View File

@@ -30,7 +30,6 @@ import {
QueryFormData,
SetDataMaskHook,
SqlaFormData,
getMapProviderMapStyle,
} from '@superset-ui/core';
import {
@@ -47,7 +46,6 @@ import { Point } from '../../types';
import { GetLayerType } from '../../factory';
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
import { BLACK_COLOR, PRIMARY_COLOR } from '../../utilities/controls';
import { getMapboxApiKey } from '../../utils/mapbox';
type ProcessedFeature = Feature<Geometry, GeoJsonProperties> & {
properties: JsonObject;
@@ -359,19 +357,9 @@ export type DeckGLGeoJsonProps = {
emitCrossFilters?: boolean;
};
export function getPoints(data?: Point[]) {
if (!Array.isArray(data)) {
return [];
}
export function getPoints(data: Point[]) {
return data.reduce((acc: Array<any>, feature: any) => {
let bounds;
try {
bounds = geojsonExtent(feature);
} catch {
return acc;
}
const bounds = geojsonExtent(feature);
if (bounds) {
return [...acc, [bounds[0], bounds[1]], [bounds[2], bounds[3]]];
}
@@ -394,13 +382,13 @@ const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
const viewport: Viewport = useMemo(() => {
if (formData.autozoom) {
const points = getPoints(payload?.data?.features);
const points = getPoints(payload.data.features) || [];
if (points.length) {
return fitViewport(props.viewport, {
width,
height,
points,
points: getPoints(payload.data.features) || [],
});
}
}
@@ -424,21 +412,12 @@ const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
emitCrossFilters: props.emitCrossFilters,
});
const selectedMap = getMapProviderMapStyle({
mapProvider: formData.map_renderer,
maplibreStyle: formData.maplibre_style,
mapboxStyle: formData.mapbox_style,
legacyMapStyle: formData.map_style,
});
return (
<DeckGLContainerStyledWrapper
ref={containerRef}
viewport={viewport}
layers={[layer]}
mapProvider={selectedMap.mapProvider}
mapStyle={selectedMap.mapStyle}
mapboxApiKey={getMapboxApiKey()}
mapStyle={formData.map_style}
setControlValue={setControlValue}
height={height}
width={width}

View File

@@ -82,7 +82,6 @@ const config: ControlPanelConfig = {
},
{
label: t('Map'),
expanded: true,
controlSetRows: [
[mapProvider],
[mapboxStyle],

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { ReactElement } from 'react';
// eslint-disable-next-line import/no-extraneous-dependencies
import { render, screen } from '@testing-library/react';
// eslint-disable-next-line import/no-extraneous-dependencies
@@ -34,23 +33,10 @@ const mockGetColorBreakpointsBuckets = jest.spyOn(
);
// Mock DeckGL container and Legend
const mockDeckGLContainerProps: Array<Record<string, unknown>> = [];
jest.mock('../../DeckGLContainer', () => ({
DeckGLContainerStyledWrapper: (props: Record<string, unknown>) => {
mockDeckGLContainerProps.push(props);
const React = jest.requireActual('react');
return React.createElement(
'div',
{ 'data-testid': 'deckgl-container' },
props.children,
);
},
}));
jest.mock('../../utils/mapbox', () => ({
getMapboxApiKey: () => 'bootstrap-mapbox-key',
hasMapboxApiKey: () => true,
DeckGLContainerStyledWrapper: ({ children }: any) => (
<div data-testid="deckgl-container">{children}</div>
),
}));
jest.mock('../../components/Legend', () => ({ categories, position }: any) => (
@@ -123,95 +109,6 @@ const mockProps = {
emitCrossFilters: false,
};
describe('DeckGLPolygon renderer propagation', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetBuckets.mockReturnValue({});
mockGetColorBreakpointsBuckets.mockReturnValue({});
});
const renderWithTheme = (component: ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
const lastDeckGLContainerProps = () =>
mockDeckGLContainerProps
.slice()
.reverse()
.find(props => props?.viewport !== undefined);
test('passes selected MapLibre renderer props to the container', () => {
mockDeckGLContainerProps.length = 0;
renderWithTheme(
<DeckGLPolygon
{...mockProps}
formData={{
...mockProps.formData,
map_renderer: 'maplibre',
maplibre_style: 'https://example.com/polygon-maplibre-style.json',
mapbox_style: 'mapbox://styles/mapbox/dark-v9',
}}
/>,
);
expect(lastDeckGLContainerProps()).toEqual(
expect.objectContaining({
mapProvider: 'maplibre',
mapStyle: 'https://example.com/polygon-maplibre-style.json',
mapboxApiKey: 'bootstrap-mapbox-key',
}),
);
});
test('passes selected Mapbox renderer props to the container', () => {
mockDeckGLContainerProps.length = 0;
renderWithTheme(
<DeckGLPolygon
{...mockProps}
formData={{
...mockProps.formData,
map_renderer: 'mapbox',
maplibre_style: 'https://example.com/polygon-maplibre-style.json',
mapbox_style: 'mapbox://styles/mapbox/satellite-v9',
}}
/>,
);
expect(lastDeckGLContainerProps()).toEqual(
expect.objectContaining({
mapProvider: 'mapbox',
mapStyle: 'mapbox://styles/mapbox/satellite-v9',
mapboxApiKey: 'bootstrap-mapbox-key',
}),
);
});
test('falls back to legacy map_style when provider-specific style is absent', () => {
mockDeckGLContainerProps.length = 0;
renderWithTheme(
<DeckGLPolygon
{...mockProps}
formData={{
...mockProps.formData,
map_renderer: 'maplibre',
maplibre_style: undefined,
map_style: 'legacy-map-style',
}}
/>,
);
expect(lastDeckGLContainerProps()).toEqual(
expect.objectContaining({
mapProvider: 'maplibre',
mapStyle: 'legacy-map-style',
mapboxApiKey: 'bootstrap-mapbox-key',
}),
);
});
});
describe('DeckGLPolygon bucket generation logic', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -222,7 +119,7 @@ describe('DeckGLPolygon bucket generation logic', () => {
mockGetColorBreakpointsBuckets.mockReturnValue({});
});
const renderWithTheme = (component: ReactElement) =>
const renderWithTheme = (component: React.ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('should use getBuckets for linear_palette color scheme', () => {
@@ -330,7 +227,7 @@ describe('DeckGLPolygon Error Handling and Edge Cases', () => {
mockGetColorBreakpointsBuckets.mockReturnValue({});
});
const renderWithTheme = (component: ReactElement) =>
const renderWithTheme = (component: React.ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('handles empty features data gracefully', () => {
@@ -394,7 +291,7 @@ describe('DeckGLPolygon Legend Integration', () => {
});
});
const renderWithTheme = (component: ReactElement) =>
const renderWithTheme = (component: React.ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('renders legend with non-empty categories when metric and linear_palette are defined', () => {

View File

@@ -31,7 +31,6 @@ import {
JsonValue,
QueryFormData,
SetDataMaskHook,
getMapProviderMapStyle,
} from '@superset-ui/core';
import { PolygonLayer } from '@deck.gl/layers';
@@ -58,7 +57,6 @@ import { TooltipProps } from '../../components/Tooltip';
import { GetLayerType } from '../../factory';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
import { DEFAULT_DECKGL_COLOR } from '../../utilities/Shared_DeckGL';
import { getMapboxApiKey } from '../../utils/mapbox';
import {
createTooltipContent,
CommonTooltipRows,
@@ -341,12 +339,6 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
colorSchemeType === COLOR_SCHEME_TYPES.color_breakpoints
? getColorBreakpointsBuckets(formData.color_breakpoints)
: getBuckets(formData, payload.data.features, accessor);
const selectedMap = getMapProviderMapStyle({
mapProvider: formData.map_renderer,
maplibreStyle: formData.maplibre_style,
mapboxStyle: formData.mapbox_style,
legacyMapStyle: formData.map_style,
});
return (
<div style={{ position: 'relative' }}>
@@ -355,9 +347,7 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
viewport={viewport}
layers={getLayers()}
setControlValue={setControlValue}
mapProvider={selectedMap.mapProvider}
mapStyle={selectedMap.mapStyle}
mapboxApiKey={getMapboxApiKey()}
mapStyle={formData.map_style}
width={props.width}
height={props.height}
/>

View File

@@ -1,161 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ControlPanelState } from '@superset-ui/chart-controls';
import { OSM_TILE_STYLE_URL } from '@superset-ui/core/utils/mapStyles';
import { mapProvider, maplibreStyle } from './Shared_DeckGL';
const setBootstrap = ({
conf = {},
deckglTiles,
}: {
conf?: Record<string, unknown>;
deckglTiles?: unknown;
}) => {
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
common: {
conf,
...(deckglTiles === undefined ? {} : { deckgl_tiles: deckglTiles }),
},
})}'></div>`;
};
type MapProviderControlConfig = typeof mapProvider.config & {
mapStateToProps: (state: ControlPanelState) => {
options?: unknown;
warning?: string;
default?: unknown;
};
};
const getMapProviderProps = (value?: string) =>
(mapProvider.config as MapProviderControlConfig).mapStateToProps({
form_data: { map_renderer: value },
} as unknown as ControlPanelState);
type MapLibreStyleControlConfig = typeof maplibreStyle.config & {
mapStateToProps: () => {
choices: unknown;
default: unknown;
};
};
const getMapLibreStyleProps = () =>
(maplibreStyle.config as MapLibreStyleControlConfig).mapStateToProps();
test('deck.gl MapLibre style choices expose Streets (OSM)', () => {
expect(maplibreStyle.config.choices).toContainEqual([
OSM_TILE_STYLE_URL,
'Streets (OSM)',
]);
});
test('deck.gl map renderer hides Mapbox when no key exists for new selections', () => {
setBootstrap({ conf: {} });
const props = getMapProviderProps('maplibre');
expect(props.options).toEqual([
{ value: 'maplibre', label: 'MapLibre (open-source)' },
]);
});
test('deck.gl map renderer keeps saved Mapbox visible while disabled without a key', () => {
setBootstrap({ conf: {} });
const props = getMapProviderProps('mapbox');
expect(props.options).toContainEqual({
value: 'mapbox',
label: 'Mapbox (API key required)',
disabled: true,
});
});
test('deck.gl map renderer enables Mapbox when a key exists', () => {
setBootstrap({ conf: { MAPBOX_API_KEY: 'pk.test' } });
const props = getMapProviderProps('maplibre');
expect(props.options).toEqual([
{ value: 'maplibre', label: 'MapLibre (open-source)' },
{ value: 'mapbox', label: 'Mapbox (API key required)' },
]);
});
test('deck.gl map renderer keeps the original explanatory description', () => {
expect(mapProvider.config.description).toBe(
'Select the map tile provider. MapLibre is open-source and requires no API key. Mapbox requires MAPBOX_API_KEY to be configured in Superset.',
);
});
test('deck.gl map renderer defaults to configured Mapbox when a key exists', () => {
setBootstrap({
conf: { DEFAULT_MAP_RENDERER: 'mapbox', MAPBOX_API_KEY: 'pk.test' },
});
expect(getMapProviderProps('maplibre').default).toBe('mapbox');
});
test('deck.gl map renderer falls back from configured Mapbox default without a key', () => {
setBootstrap({ conf: { DEFAULT_MAP_RENDERER: 'mapbox' } });
expect(getMapProviderProps('maplibre').default).toBe('maplibre');
});
test('deck.gl map style falls back to default tiles for empty overrides', () => {
setBootstrap({ deckglTiles: [] });
const props = getMapLibreStyleProps();
expect(props.choices).toContainEqual([OSM_TILE_STYLE_URL, 'Streets (OSM)']);
expect(props.default).toBe(
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
);
});
test('deck.gl map style falls back to default tiles for malformed overrides', () => {
setBootstrap({
deckglTiles: [
['https://tiles.example.com/{z}/{x}/{y}.png'],
['https://tiles.example.com/{z}/{x}/{y}.png', 'Custom', 'Extra'],
['', 'Empty URL'],
],
});
const props = getMapLibreStyleProps();
expect(props.choices).toContainEqual([OSM_TILE_STYLE_URL, 'Streets (OSM)']);
expect(props.default).toBe(
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
);
});
test('deck.gl map style accepts well-formed tile overrides', () => {
setBootstrap({
deckglTiles: [['https://tiles.example.com/style.json', 'Custom']],
});
const props = getMapLibreStyleProps();
expect(props.choices).toEqual([
['https://tiles.example.com/style.json', 'Custom'],
]);
expect(props.default).toBe('https://tiles.example.com/style.json');
});

View File

@@ -25,20 +25,9 @@ import {
getCategoricalSchemeRegistry,
getSequentialSchemeRegistry,
SequentialScheme,
type QueryFormData,
} from '@superset-ui/core';
import {
getDefaultMapRenderer,
getBootstrapDataFromDocument,
getMapRendererOptions,
OSM_TILE_STYLE_URL,
type MapRendererOption,
type MapProvider,
} from '@superset-ui/core/utils/mapStyles';
import {
ControlPanelState,
ControlStateMapping,
ControlState,
CustomControlItem,
D3_FORMAT_OPTIONS,
getColorControlsProps,
@@ -51,23 +40,15 @@ import {
isColorSchemeTypeVisible,
} from './utils';
import { TooltipTemplateControl } from './TooltipTemplateControl';
import { hasMapboxApiKey } from '../utils/mapbox';
const categoricalSchemeRegistry = getCategoricalSchemeRegistry();
const sequentialSchemeRegistry = getSequentialSchemeRegistry();
export const DEFAULT_DECKGL_COLOR = { r: 158, g: 158, b: 158, a: 1 };
type DeckGLTileChoice = [string, string];
type MapStyleVisibilityProps = {
controls?: ControlStateMapping;
};
type MetricControlValue = {
type?: unknown;
value?: unknown;
};
let deckglTiles: string[][];
export const DEFAULT_DECKGL_TILES: DeckGLTileChoice[] = [
export const DEFAULT_DECKGL_TILES = [
[
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
'Light (Carto)',
@@ -81,10 +62,9 @@ export const DEFAULT_DECKGL_TILES: DeckGLTileChoice[] = [
'Streets (Carto)',
],
['https://tiles.openfreemap.org/styles/liberty', 'Liberty (OpenFreeMap)'],
[OSM_TILE_STYLE_URL, 'Streets (OSM)'],
];
export const DEFAULT_MAPBOX_TILES: DeckGLTileChoice[] = [
export const DEFAULT_MAPBOX_TILES = [
['mapbox://styles/mapbox/streets-v9', 'Streets (Mapbox)'],
['mapbox://styles/mapbox/dark-v9', 'Dark (Mapbox)'],
['mapbox://styles/mapbox/light-v9', 'Light (Mapbox)'],
@@ -93,56 +73,17 @@ export const DEFAULT_MAPBOX_TILES: DeckGLTileChoice[] = [
['mapbox://styles/mapbox/outdoors-v9', 'Outdoors (Mapbox)'],
];
const isDeckGLTileChoices = (value: unknown): value is DeckGLTileChoice[] =>
Array.isArray(value) &&
value.length > 0 &&
value.every(
choice =>
Array.isArray(choice) &&
choice.length === 2 &&
typeof choice[0] === 'string' &&
choice[0].trim().length > 0 &&
typeof choice[1] === 'string' &&
choice[1].trim().length > 0,
);
const getDeckGLTiles = () => {
const bootstrapData = getBootstrapDataFromDocument();
const deckglTilesOverride = (
bootstrapData as {
common?: { deckgl_tiles?: unknown };
} | null
)?.common?.deckgl_tiles;
return isDeckGLTileChoices(deckglTilesOverride)
? deckglTilesOverride
: DEFAULT_DECKGL_TILES;
if (!deckglTiles) {
const appContainer = document.getElementById('app');
const { common } = JSON.parse(
appContainer?.getAttribute('data-bootstrap') || '{}',
);
deckglTiles = common?.deckgl_tiles ?? DEFAULT_DECKGL_TILES;
}
return deckglTiles;
};
const getMapLibreStyleProps = () => {
const choices = getDeckGLTiles();
return {
choices,
default: choices[0][0],
};
};
const getLabeledMapRendererOptions = ({
hasMapboxKey,
currentValue,
}: {
hasMapboxKey: boolean;
currentValue?: MapProvider;
}) =>
getMapRendererOptions({ hasMapboxKey, currentValue }).map(
(option: MapRendererOption) => ({
...option,
label:
option.value === 'maplibre'
? t('MapLibre (open-source)')
: t('Mapbox (API key required)'),
}),
);
const DEFAULT_VIEWPORT = {
longitude: 6.85236157047845,
latitude: 31.222656842808707,
@@ -515,26 +456,15 @@ export const mapProvider = {
label: t('Map Renderer'),
clearable: false,
renderTrigger: true,
options: getLabeledMapRendererOptions({
hasMapboxKey: hasMapboxApiKey(),
}),
choices: [
['maplibre', t('MapLibre (open-source)')],
['mapbox', t('Mapbox (API key required)')],
],
default: 'maplibre',
description: t(
'Select the map tile provider. MapLibre is open-source and requires no API key. ' +
'Mapbox requires MAPBOX_API_KEY to be configured in Superset.',
),
mapStateToProps: (state: ControlPanelState) => {
const hasKey = hasMapboxApiKey();
return {
options: getLabeledMapRendererOptions({
hasMapboxKey: hasKey,
currentValue: state.form_data?.map_renderer as
| MapProvider
| undefined,
}),
default: getDefaultMapRenderer(),
};
},
},
};
@@ -546,14 +476,13 @@ export const maplibreStyle = {
clearable: false,
renderTrigger: true,
freeForm: true,
choices: DEFAULT_DECKGL_TILES,
default: DEFAULT_DECKGL_TILES[0][0],
choices: getDeckGLTiles(),
default: getDeckGLTiles()[0][0],
description: t(
'Base layer map style. Accepts a MapLibre-compatible style URL.',
),
visibility: ({ controls }: MapStyleVisibilityProps) =>
visibility: ({ controls }: ControlPanelState) =>
controls?.map_renderer?.value !== 'mapbox',
mapStateToProps: getMapLibreStyleProps,
},
};
@@ -570,7 +499,7 @@ export const mapboxStyle = {
description: t(
'Base layer map style. Accepts a Mapbox style URL (mapbox://styles/...).',
),
visibility: ({ controls }: MapStyleVisibilityProps) =>
visibility: ({ controls }: ControlPanelState) =>
controls?.map_renderer?.value === 'mapbox',
},
};
@@ -588,14 +517,14 @@ export const geojsonColumn = {
},
};
const extractMetricsFromFormData = (formData: QueryFormData) => {
const metrics = new Set<unknown>();
const extractMetricsFromFormData = (formData: any) => {
const metrics = new Set<string>();
if (formData.metrics) {
(Array.isArray(formData.metrics)
? formData.metrics
: [formData.metrics]
).forEach((metric: unknown) => metrics.add(metric));
).forEach((metric: any) => metrics.add(metric));
}
if (formData.point_radius_fixed?.value) {
@@ -604,9 +533,8 @@ const extractMetricsFromFormData = (formData: QueryFormData) => {
Object.entries(formData).forEach(([, value]) => {
if (!value || typeof value !== 'object') return;
const controlValue = value as MetricControlValue;
if (controlValue.type === 'metric' && controlValue.value) {
metrics.add(controlValue.value);
if ((value as any).type === 'metric' && (value as any).value) {
metrics.add((value as any).value);
}
});
@@ -627,7 +555,7 @@ export const tooltipContents = {
),
ghostButtonText: t('Drop columns/metrics here or click'),
disabledTabs: new Set(['saved', 'sqlExpression']),
mapStateToProps: (state: ControlPanelState) => {
mapStateToProps: (state: any) => {
const { datasource, form_data: formData } = state;
const selectedMetrics = formData
@@ -636,8 +564,7 @@ export const tooltipContents = {
return {
columns: datasource?.columns || [],
savedMetrics:
datasource && 'metrics' in datasource ? datasource.metrics || [] : [],
savedMetrics: datasource?.metrics || [],
datasource,
selectedMetrics,
disabledTabs: new Set(['saved', 'sqlExpression']),
@@ -657,7 +584,7 @@ export const tooltipTemplate = {
default: '',
description: '',
placeholder: '',
mapStateToProps: (_state: ControlPanelState, control: ControlState) => ({
mapStateToProps: (_state: any, control: any) => ({
value: control.value,
}),
},
@@ -775,13 +702,8 @@ export const deckGLBreakpointMetric: CustomControlItem = {
// mapStateToProps: (state: ControlPanelState) => ({
// datasource: state.datasource,
// }),
visibility: ({ controls }: MapStyleVisibilityProps) =>
controls
? isColorSchemeTypeVisible(
controls,
COLOR_SCHEME_TYPES.color_breakpoints,
)
: false,
visibility: ({ controls }: { controls: any }) =>
isColorSchemeTypeVisible(controls, COLOR_SCHEME_TYPES.color_breakpoints),
},
};

View File

@@ -1,38 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { getMapboxApiKey, hasMapboxApiKey } from './mapbox';
const setBootstrap = (conf: Record<string, unknown>) => {
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
common: { conf },
})}'></div>`;
};
test('deck.gl Mapbox helpers read key presence from bootstrap data', () => {
setBootstrap({ MAPBOX_API_KEY: 'pk.test' });
expect(getMapboxApiKey()).toBe('pk.test');
expect(hasMapboxApiKey()).toBe(true);
setBootstrap({});
expect(getMapboxApiKey()).toBe('');
expect(hasMapboxApiKey()).toBe(false);
});

View File

@@ -17,15 +17,19 @@
* under the License.
*/
import {
getMapboxApiKeyFromBootstrap,
hasMapboxApiKey as hasBootstrapMapboxApiKey,
} from '@superset-ui/core/utils/mapStyles';
export function getMapboxApiKey(): string {
return getMapboxApiKeyFromBootstrap();
}
export function hasMapboxApiKey(): boolean {
return hasBootstrapMapboxApiKey();
if (typeof document === 'undefined') {
return '';
}
try {
const appContainer = document.getElementById('app');
const dataBootstrap = appContainer?.getAttribute('data-bootstrap');
if (dataBootstrap) {
const bootstrapData = JSON.parse(dataBootstrap);
return bootstrapData?.common?.conf?.MAPBOX_API_KEY || '';
}
} catch {
// If bootstrap data is unavailable or malformed, return empty string
}
return '';
}

Some files were not shown because too many files have changed in this diff Show More