mirror of
https://github.com/apache/superset.git
synced 2026-06-13 11:39:16 +00:00
Compare commits
2 Commits
ci/only-ru
...
fix/helm-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de30eed14f | ||
|
|
3da2a210c7 |
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -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}}"
|
||||
|
||||
2
.github/workflows/pre-commit.yml
vendored
2
.github/workflows/pre-commit.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
1
.github/workflows/superset-e2e.yml
vendored
1
.github/workflows/superset-e2e.yml
vendored
@@ -52,7 +52,6 @@ jobs:
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
# Somehow one test flakes on 24.04 for unknown reasons, this is the only GHA left on 22.04
|
||||
runs-on: ubuntu-22.04
|
||||
if: github.event.pull_request.draft == false
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -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
|
||||
|
||||
21
UPDATING.md
21
UPDATING.md
@@ -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`.
|
||||
|
||||
@@ -1 +1 @@
|
||||
v24.16.0
|
||||
v22.22.0
|
||||
|
||||
@@ -29,7 +29,7 @@ maintainers:
|
||||
- name: craig-rueda
|
||||
email: craig@craigrueda.com
|
||||
url: https://github.com/craig-rueda
|
||||
version: 0.16.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
version: 0.17.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 16.7.27
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
@@ -111,9 +111,6 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
| init.resources | object | `{}` | |
|
||||
| init.tolerations | list | `[]` | |
|
||||
| init.topologySpreadConstraints | list | `[]` | TopologySpreadConstrains to be added to init job |
|
||||
| initImage.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| initImage.repository | string | `"apache/superset"` | |
|
||||
| initImage.tag | string | `"dockerize"` | |
|
||||
| nameOverride | string | `nil` | Provide a name to override the name of the chart |
|
||||
| nodeSelector | object | `{}` | |
|
||||
| postgresql | object | see `values.yaml` | Configuration values for the postgresql dependency. ref: https://github.com/bitnami/charts/tree/main/bitnami/postgresql |
|
||||
|
||||
@@ -194,11 +194,6 @@ image:
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
initImage:
|
||||
repository: apache/superset
|
||||
tag: dockerize
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8088
|
||||
@@ -303,15 +298,28 @@ supersetNode:
|
||||
# @default -- a container waiting for postgres
|
||||
initContainers:
|
||||
- name: wait-for-postgres
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -timeout 120s
|
||||
- |
|
||||
# bash's /dev/tcp redirect performs a TCP connect; no external
|
||||
# `dockerize`, `nc`, or busybox needed. SECONDS-based deadline
|
||||
# mirrors the prior `dockerize -timeout 120s` behaviour.
|
||||
SECONDS=0
|
||||
until (echo > /dev/tcp/"$DB_HOST"/"$DB_PORT") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for postgres at $DB_HOST:$DB_PORT after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for postgres at $DB_HOST:$DB_PORT (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "postgres at $DB_HOST:$DB_PORT is up"
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
@@ -407,15 +415,31 @@ supersetWorker:
|
||||
# @default -- a container waiting for postgres and redis
|
||||
initContainers:
|
||||
- name: wait-for-postgres-redis
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -wait "tcp://$REDIS_HOST:$REDIS_PORT" -timeout 120s
|
||||
- |
|
||||
# See supersetNode.initContainers for the rationale.
|
||||
SECONDS=0
|
||||
wait_for() {
|
||||
local host=$1 port=$2 name=$3
|
||||
until (echo > /dev/tcp/"$host"/"$port") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for $name at $host:$port after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for $name at $host:$port (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "$name at $host:$port is up"
|
||||
}
|
||||
wait_for "$DB_HOST" "$DB_PORT" postgres
|
||||
wait_for "$REDIS_HOST" "$REDIS_PORT" redis
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
@@ -495,15 +519,31 @@ supersetCeleryBeat:
|
||||
# @default -- a container waiting for postgres
|
||||
initContainers:
|
||||
- name: wait-for-postgres-redis
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -wait "tcp://$REDIS_HOST:$REDIS_PORT" -timeout 120s
|
||||
- |
|
||||
# See supersetNode.initContainers for the rationale.
|
||||
SECONDS=0
|
||||
wait_for() {
|
||||
local host=$1 port=$2 name=$3
|
||||
until (echo > /dev/tcp/"$host"/"$port") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for $name at $host:$port after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for $name at $host:$port (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "$name at $host:$port is up"
|
||||
}
|
||||
wait_for "$DB_HOST" "$DB_PORT" postgres
|
||||
wait_for "$REDIS_HOST" "$REDIS_PORT" redis
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
@@ -594,15 +634,31 @@ supersetCeleryFlower:
|
||||
# @default -- a container waiting for postgres and redis
|
||||
initContainers:
|
||||
- name: wait-for-postgres-redis
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -wait "tcp://$REDIS_HOST:$REDIS_PORT" -timeout 120s
|
||||
- |
|
||||
# See supersetNode.initContainers for the rationale.
|
||||
SECONDS=0
|
||||
wait_for() {
|
||||
local host=$1 port=$2 name=$3
|
||||
until (echo > /dev/tcp/"$host"/"$port") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for $name at $host:$port after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for $name at $host:$port (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "$name at $host:$port is up"
|
||||
}
|
||||
wait_for "$DB_HOST" "$DB_PORT" postgres
|
||||
wait_for "$REDIS_HOST" "$REDIS_PORT" redis
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
@@ -764,15 +820,26 @@ init:
|
||||
# @default -- a container waiting for postgres
|
||||
initContainers:
|
||||
- name: wait-for-postgres
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -timeout 120s
|
||||
- |
|
||||
# See supersetNode.initContainers for the rationale.
|
||||
SECONDS=0
|
||||
until (echo > /dev/tcp/"$DB_HOST"/"$DB_PORT") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for postgres at $DB_HOST:$DB_PORT after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for postgres at $DB_HOST:$DB_PORT (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "postgres at $DB_HOST:$DB_PORT is up"
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
|
||||
@@ -143,7 +143,7 @@ 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",
|
||||
@@ -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"]
|
||||
|
||||
@@ -1 +1 @@
|
||||
v24.16.0
|
||||
v22.22.0
|
||||
|
||||
@@ -1 +1 @@
|
||||
v24.16.0
|
||||
v22.22.0
|
||||
|
||||
57
superset-frontend/cypress-base/package-lock.json
generated
57
superset-frontend/cypress-base/package-lock.json
generated
@@ -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",
|
||||
|
||||
996
superset-frontend/package-lock.json
generated
996
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -382,8 +382,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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -35,4 +35,3 @@ export * from './typedMemo';
|
||||
export * from './html';
|
||||
export * from './tooltip';
|
||||
export * from './merge';
|
||||
export * from './mapStyles';
|
||||
|
||||
@@ -1,260 +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('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('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',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)'),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -82,7 +82,6 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
{
|
||||
label: t('Map'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[mapProvider],
|
||||
[mapboxStyle],
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
v24.16.0
|
||||
v22.22.0
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
# 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.
|
||||
FROM node:24-alpine AS build
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
# Harden `npm ci` against transient npm-registry network blips (e.g. ECONNRESET),
|
||||
# which otherwise fail the image build with no retry.
|
||||
@@ -29,7 +29,7 @@ RUN npm ci && \
|
||||
npm run build
|
||||
|
||||
|
||||
FROM node:24-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
# Retry npm-registry fetches so a transient blip doesn't fail the build.
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
4
superset-websocket/package-lock.json
generated
4
superset-websocket/package-lock.json
generated
@@ -40,8 +40,8 @@
|
||||
"typescript-eslint": "^8.60.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.16.0",
|
||||
"npm": "^11.13.0"
|
||||
"node": "^22.22.0",
|
||||
"npm": "^10.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@aashutoshrathi/word-wrap": {
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"typescript-eslint": "^8.60.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.16.0",
|
||||
"npm": "^11.13.0"
|
||||
"node": "^22.22.0",
|
||||
"npm": "^10.8.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
v24.16.0
|
||||
v22.22.0
|
||||
|
||||
@@ -22,22 +22,13 @@ from typing import Any, TypedDict
|
||||
from flask import current_app as app
|
||||
from flask_babel import gettext as __
|
||||
|
||||
from superset import db, is_feature_enabled, security_manager
|
||||
from superset import db, security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import (
|
||||
SupersetDisallowedSQLFunctionException,
|
||||
SupersetDisallowedSQLTableException,
|
||||
SupersetDMLNotAllowedException,
|
||||
SupersetErrorException,
|
||||
SupersetTimeoutException,
|
||||
)
|
||||
from superset.exceptions import SupersetErrorException, SupersetTimeoutException
|
||||
from superset.jinja_context import get_template_processor
|
||||
from superset.models.core import Database
|
||||
from superset.models.sql_lab import Query
|
||||
from superset.sql.parse import SQLScript
|
||||
from superset.utils import core as utils
|
||||
from superset.utils.rls import apply_rls
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -78,85 +69,6 @@ class QueryEstimationCommand(BaseCommand):
|
||||
)
|
||||
security_manager.raise_for_access(database=self._database)
|
||||
|
||||
def _apply_sql_security(self, sql: str) -> str:
|
||||
"""Run the disallowed-function/table, DML and RLS controls against the
|
||||
SQL to be estimated, mirroring ``sql_lab.execute_sql_statements``.
|
||||
|
||||
Returns the SQL with RLS predicates injected (when ``RLS_IN_SQLLAB`` is
|
||||
enabled), so the cost estimate reflects the same constrained query the
|
||||
user would actually be allowed to run.
|
||||
"""
|
||||
db_engine_spec = self._database.db_engine_spec
|
||||
parsed_script = SQLScript(sql, engine=db_engine_spec.engine)
|
||||
|
||||
disallowed_functions = app.config["DISALLOWED_SQL_FUNCTIONS"].get(
|
||||
db_engine_spec.engine,
|
||||
set(),
|
||||
)
|
||||
if disallowed_functions and parsed_script.check_functions_present(
|
||||
disallowed_functions
|
||||
):
|
||||
raise SupersetDisallowedSQLFunctionException(disallowed_functions)
|
||||
|
||||
disallowed_tables = app.config["DISALLOWED_SQL_TABLES"].get(
|
||||
db_engine_spec.engine,
|
||||
set(),
|
||||
)
|
||||
if disallowed_tables and parsed_script.check_tables_present(disallowed_tables):
|
||||
found_tables = set()
|
||||
for statement in parsed_script.statements:
|
||||
present = {table.table.lower() for table in statement.tables}
|
||||
for table in disallowed_tables:
|
||||
if table.lower() in present:
|
||||
found_tables.add(table)
|
||||
raise SupersetDisallowedSQLTableException(found_tables or disallowed_tables)
|
||||
|
||||
if parsed_script.has_mutation() and not self._database.allow_dml:
|
||||
raise SupersetDMLNotAllowedException()
|
||||
|
||||
if is_feature_enabled("RLS_IN_SQLLAB"):
|
||||
# Resolve the default catalog/schema the same way the execution path
|
||||
# does (``sql_lab.execute_sql_statements``) before injecting RLS.
|
||||
# Crucially this goes through ``get_default_schema_for_query`` rather
|
||||
# than the plain ``get_default_schema``, so engine-specific per-query
|
||||
# security gates run too — e.g. ``PostgresEngineSpec`` rejects a query
|
||||
# that sets ``search_path``. Resolving against the static default
|
||||
# schema instead would both skip that gate and let unqualified tables
|
||||
# dodge the RLS predicates the real query enforces, defeating the
|
||||
# security parity this command exists to provide.
|
||||
catalog = self._catalog or self._database.get_default_catalog()
|
||||
# Build a transient (unsaved) Query so the engine spec can resolve the
|
||||
# effective per-query schema exactly as the executor does. Mirror the
|
||||
# probe built in ``SupersetSecurityManager.raise_for_access``: set a
|
||||
# ``client_id`` (the column is ``nullable=False``) and expunge it, so
|
||||
# the ``database`` backref's ``cascade="all, delete-orphan"`` cannot
|
||||
# autoflush this incomplete row into the session when ``apply_rls``
|
||||
# issues its own ``db.session`` query below.
|
||||
probe_query = Query(
|
||||
database=self._database,
|
||||
sql=self._sql,
|
||||
schema=self._schema or None,
|
||||
catalog=catalog,
|
||||
client_id=utils.shortid()[:10],
|
||||
user_id=utils.get_user_id(),
|
||||
)
|
||||
db.session.expunge(probe_query)
|
||||
# Always resolve through ``get_default_schema_for_query`` — even when
|
||||
# the caller pinned a schema — so the engine's per-query security gate
|
||||
# runs (e.g. ``PostgresEngineSpec`` rejects a query that sets
|
||||
# ``search_path``), exactly as the executor does unconditionally. Only
|
||||
# the resulting value falls back to the resolved default; an explicit
|
||||
# schema still wins for the RLS predicate target.
|
||||
resolved_schema = self._database.get_default_schema_for_query(
|
||||
probe_query, self._template_params
|
||||
)
|
||||
schema = self._schema or resolved_schema or ""
|
||||
for statement in parsed_script.statements:
|
||||
apply_rls(self._database, catalog, schema, statement)
|
||||
return parsed_script.format()
|
||||
|
||||
return sql
|
||||
|
||||
def run(
|
||||
self,
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -167,12 +79,6 @@ class QueryEstimationCommand(BaseCommand):
|
||||
template_processor = get_template_processor(self._database)
|
||||
sql = template_processor.process_template(sql, **self._template_params)
|
||||
|
||||
# Apply the same SQL security controls used by the execution path
|
||||
# (sql_lab.execute_sql_statements) so cost estimation cannot be used to
|
||||
# probe disallowed functions/tables, bypass the DML guard, or confirm
|
||||
# the existence of rows hidden by row-level security.
|
||||
sql = self._apply_sql_security(sql)
|
||||
|
||||
timeout = app.config["SQLLAB_QUERY_COST_ESTIMATE_TIMEOUT"]
|
||||
timeout_msg = f"The estimation exceeded the {timeout} seconds timeout."
|
||||
try:
|
||||
|
||||
@@ -506,10 +506,6 @@ D3_FORMAT: D3Format = {}
|
||||
# Add also map url in connect-src of TALISMAN_CONFIG variable
|
||||
DECKGL_BASE_MAP: list[list[str, str]] = None
|
||||
|
||||
# Default map renderer for map visualizations that support multiple providers.
|
||||
# Set to "mapbox" only in deployments that also configure MAPBOX_API_KEY.
|
||||
DEFAULT_MAP_RENDERER = os.environ.get("DEFAULT_MAP_RENDERER", "maplibre")
|
||||
|
||||
|
||||
# Override the default d3 locale for time format
|
||||
# Default values are equivalent to
|
||||
|
||||
@@ -24,13 +24,7 @@ class DashboardPermalinkStateSchema(Schema):
|
||||
metadata={"description": "Data mask used for native filter state"},
|
||||
)
|
||||
activeTabs = fields.List( # noqa: N815
|
||||
# ``allow_none`` on the inner ``fields.String`` is required because
|
||||
# legacy v5 dashboard exports persist ``null`` entries inside the
|
||||
# ``activeTabs`` list (one ``null`` per tab level that has no active
|
||||
# child). Without it the import path through this schema rejects
|
||||
# the whole permalink with ``{'activeTabs': {N: ['Field may not be
|
||||
# null.']}}`` (#40934).
|
||||
fields.String(allow_none=True),
|
||||
fields.String(),
|
||||
required=False,
|
||||
allow_none=True,
|
||||
metadata={"description": "Current active dashboard tabs"},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"""migrate mapbox and deckgl charts to point_cluster_map
|
||||
|
||||
Revision ID: ce6bd21901ab
|
||||
Revises: a1b2c3d4e5f6
|
||||
Revises: 4b2a8c9d3e1f
|
||||
Create Date: 2026-03-02 00:00:00.000000
|
||||
|
||||
|
||||
@@ -59,31 +59,6 @@ DECKGL_VIZ_TYPES = [
|
||||
"deck_scatter",
|
||||
"deck_screengrid",
|
||||
]
|
||||
DECKGL_MIGRATION_ADDED_FIELDS = "__deckgl_maplibre_migration_added_fields"
|
||||
|
||||
|
||||
def _is_mapbox_style(style: Any) -> bool:
|
||||
return isinstance(style, str) and style.startswith("mapbox://")
|
||||
|
||||
|
||||
def _copy_legacy_maplibre_style(
|
||||
data: dict[str, Any], added_fields: list[str] | None = None
|
||||
) -> bool:
|
||||
mapbox_style = data.get("mapbox_style")
|
||||
if (
|
||||
isinstance(mapbox_style, str)
|
||||
and not _is_mapbox_style(mapbox_style)
|
||||
and "maplibre_style" not in data
|
||||
):
|
||||
data["maplibre_style"] = mapbox_style
|
||||
if added_fields is not None:
|
||||
added_fields.append("maplibre_style")
|
||||
if "map_renderer" not in data:
|
||||
data["map_renderer"] = "maplibre"
|
||||
if added_fields is not None:
|
||||
added_fields.append("map_renderer")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class MigrateMapBox(MigrateViz):
|
||||
@@ -103,10 +78,8 @@ class MigrateMapBox(MigrateViz):
|
||||
# Set map_renderer so the new chart continues to use the Mapbox renderer,
|
||||
# which will pick up MAPBOX_API_KEY from the server config.
|
||||
mapbox_style = self.data.get("mapbox_style", "")
|
||||
if _is_mapbox_style(mapbox_style):
|
||||
if isinstance(mapbox_style, str) and mapbox_style.startswith("mapbox://"):
|
||||
self.data["map_renderer"] = "mapbox"
|
||||
else:
|
||||
_copy_legacy_maplibre_style(self.data)
|
||||
|
||||
@classmethod
|
||||
def upgrade_slice(cls, slc: Slice) -> None:
|
||||
@@ -143,33 +116,22 @@ class MigrateMapBox(MigrateViz):
|
||||
|
||||
|
||||
def _migrate_deckgl_slice(slc: Slice) -> bool:
|
||||
"""Preserve deck.gl renderer/style state after the MapLibre migration.
|
||||
"""Set map_renderer='mapbox' for all existing deck.gl slices.
|
||||
|
||||
True Mapbox styles get map_renderer='mapbox'. Non-Mapbox legacy
|
||||
mapbox_style values are copied to maplibre_style so the MapLibre path keeps
|
||||
rendering the saved style value.
|
||||
This ensures full backwards compatibility: existing charts keep using the
|
||||
Mapbox renderer. Users can later switch to MapLibre in the chart controls.
|
||||
Only new charts will default to MapLibre.
|
||||
|
||||
Returns True if the slice was modified.
|
||||
"""
|
||||
params = try_load_json(slc.params)
|
||||
if not isinstance(params, dict) or not params:
|
||||
if not params:
|
||||
return False
|
||||
|
||||
modified = False
|
||||
added_fields: list[str] = []
|
||||
|
||||
mapbox_style = params.get("mapbox_style", "")
|
||||
if _is_mapbox_style(mapbox_style):
|
||||
if "map_renderer" not in params:
|
||||
params["map_renderer"] = "mapbox"
|
||||
added_fields.append("map_renderer")
|
||||
modified = True
|
||||
else:
|
||||
modified = _copy_legacy_maplibre_style(params, added_fields)
|
||||
|
||||
if not modified:
|
||||
if "map_renderer" in params:
|
||||
return False
|
||||
params[DECKGL_MIGRATION_ADDED_FIELDS] = added_fields
|
||||
|
||||
params["map_renderer"] = "mapbox"
|
||||
slc.params = json.dumps(params)
|
||||
return True
|
||||
|
||||
@@ -177,18 +139,10 @@ def _migrate_deckgl_slice(slc: Slice) -> bool:
|
||||
def _downgrade_deckgl_slice(slc: Slice) -> bool:
|
||||
"""Reverse _migrate_deckgl_slice. Returns True if the slice was modified."""
|
||||
params = try_load_json(slc.params)
|
||||
if not isinstance(params, dict) or not params:
|
||||
if not params or "map_renderer" not in params:
|
||||
return False
|
||||
|
||||
added_fields = params.get(DECKGL_MIGRATION_ADDED_FIELDS)
|
||||
if not isinstance(added_fields, list):
|
||||
return False
|
||||
|
||||
for field in added_fields:
|
||||
if field in {"map_renderer", "maplibre_style"} and field in params:
|
||||
params.pop(field, None)
|
||||
|
||||
params.pop(DECKGL_MIGRATION_ADDED_FIELDS, None)
|
||||
params.pop("map_renderer", None)
|
||||
slc.params = json.dumps(params)
|
||||
return True
|
||||
|
||||
|
||||
@@ -122,7 +122,6 @@ FRONTEND_CONF_KEYS = (
|
||||
"SYNC_DB_PERMISSIONS_IN_ASYNC_MODE",
|
||||
"TABLE_VIZ_MAX_ROW_SERVER",
|
||||
"MAPBOX_API_KEY",
|
||||
"DEFAULT_MAP_RENDERER",
|
||||
"CSV_STREAMING_ROW_THRESHOLD",
|
||||
)
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ migrate_deckgl_and_mapbox = import_module(
|
||||
|
||||
Slice = migrate_deckgl_and_mapbox.Slice
|
||||
MigrateMapBox = migrate_deckgl_and_mapbox.MigrateMapBox
|
||||
DECKGL_MIGRATION_ADDED_FIELDS = migrate_deckgl_and_mapbox.DECKGL_MIGRATION_ADDED_FIELDS
|
||||
_migrate_deckgl_slice = migrate_deckgl_and_mapbox._migrate_deckgl_slice
|
||||
_downgrade_deckgl_slice = migrate_deckgl_and_mapbox._downgrade_deckgl_slice
|
||||
|
||||
@@ -80,7 +79,7 @@ def test_upgrade_mapbox():
|
||||
|
||||
@pytest.mark.usefixtures("app_context")
|
||||
def test_upgrade_mapbox_with_non_mapbox_style():
|
||||
"""Charts with non-mapbox:// style URLs should stay on the MapLibre path."""
|
||||
"""Charts with non-mapbox:// style URLs should not get map_provider=mapbox."""
|
||||
slc = Slice(
|
||||
slice_name="Test Mapbox Open Style",
|
||||
viz_type="mapbox",
|
||||
@@ -99,135 +98,17 @@ def test_upgrade_mapbox_with_non_mapbox_style():
|
||||
assert slc.viz_type == "point_cluster_map"
|
||||
params = json.loads(slc.params)
|
||||
assert params["mapbox_style"] == "https://tiles.openfreemap.org/styles/liberty"
|
||||
assert params["maplibre_style"] == "https://tiles.openfreemap.org/styles/liberty"
|
||||
assert params["map_renderer"] == "maplibre"
|
||||
assert "map_renderer" not in params
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"mapbox_style",
|
||||
"expected_map_renderer",
|
||||
"expected_maplibre_style",
|
||||
"expected_modified",
|
||||
),
|
||||
[
|
||||
("mapbox://styles/mapbox/dark-v9", "mapbox", None, True),
|
||||
(
|
||||
"tile://https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
"maplibre",
|
||||
"tile://https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
"maplibre",
|
||||
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
True,
|
||||
),
|
||||
(None, None, None, False),
|
||||
(
|
||||
"https://example.com/styles/custom-style.json",
|
||||
"maplibre",
|
||||
"https://example.com/styles/custom-style.json",
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_migrate_deckgl_slice_map_renderer_classification(
|
||||
mapbox_style, expected_map_renderer, expected_maplibre_style, expected_modified
|
||||
):
|
||||
params = {
|
||||
"viz_type": "deck_arc",
|
||||
"other_param": "value",
|
||||
}
|
||||
if mapbox_style is not None:
|
||||
params["mapbox_style"] = mapbox_style
|
||||
|
||||
def test_migrate_deckgl_slice_mapbox_style():
|
||||
slc = Slice(
|
||||
slice_name="Test Arc",
|
||||
viz_type="deck_arc",
|
||||
params=json.dumps(params),
|
||||
)
|
||||
|
||||
modified = _migrate_deckgl_slice(slc)
|
||||
|
||||
assert modified is expected_modified
|
||||
migrated_params = json.loads(slc.params)
|
||||
if mapbox_style is not None:
|
||||
assert migrated_params["mapbox_style"] == mapbox_style
|
||||
else:
|
||||
assert "mapbox_style" not in migrated_params
|
||||
if expected_map_renderer is None:
|
||||
assert "map_renderer" not in migrated_params
|
||||
else:
|
||||
assert migrated_params["map_renderer"] == expected_map_renderer
|
||||
if expected_maplibre_style is None:
|
||||
assert "maplibre_style" not in migrated_params
|
||||
else:
|
||||
assert migrated_params["maplibre_style"] == expected_maplibre_style
|
||||
if expected_modified:
|
||||
assert DECKGL_MIGRATION_ADDED_FIELDS in migrated_params
|
||||
else:
|
||||
assert DECKGL_MIGRATION_ADDED_FIELDS not in migrated_params
|
||||
assert migrated_params["viz_type"] == "deck_arc" # viz_type unchanged
|
||||
assert migrated_params["other_param"] == "value"
|
||||
|
||||
|
||||
def test_migrate_deckgl_slice_preserves_existing_maplibre_style():
|
||||
slc = Slice(
|
||||
slice_name="Test Arc Existing MapLibre Style",
|
||||
viz_type="deck_arc",
|
||||
params=json.dumps(
|
||||
{
|
||||
"viz_type": "deck_arc",
|
||||
"mapbox_style": "https://legacy.example.com/style.json",
|
||||
"maplibre_style": "https://saved.example.com/style.json",
|
||||
"other_param": "value",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
modified = _migrate_deckgl_slice(slc)
|
||||
|
||||
assert modified is False
|
||||
params = json.loads(slc.params)
|
||||
assert params["mapbox_style"] == "https://legacy.example.com/style.json"
|
||||
assert params["maplibre_style"] == "https://saved.example.com/style.json"
|
||||
assert params["other_param"] == "value"
|
||||
|
||||
|
||||
def test_migrate_deckgl_slice_preserves_existing_map_renderer():
|
||||
slc = Slice(
|
||||
slice_name="Test Arc Existing Renderer",
|
||||
viz_type="deck_arc",
|
||||
params=json.dumps(
|
||||
{
|
||||
"viz_type": "deck_arc",
|
||||
"mapbox_style": "mapbox://styles/mapbox/dark-v9",
|
||||
"map_renderer": "maplibre",
|
||||
"other_param": "value",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
modified = _migrate_deckgl_slice(slc)
|
||||
|
||||
assert modified is False
|
||||
params = json.loads(slc.params)
|
||||
assert params["map_renderer"] == "maplibre"
|
||||
assert params["mapbox_style"] == "mapbox://styles/mapbox/dark-v9"
|
||||
assert params["other_param"] == "value"
|
||||
|
||||
|
||||
def test_migrate_deckgl_slice_copies_style_without_overwriting_renderer():
|
||||
slc = Slice(
|
||||
slice_name="Test Arc Existing Renderer Open Style",
|
||||
viz_type="deck_arc",
|
||||
params=json.dumps(
|
||||
{
|
||||
"viz_type": "deck_arc",
|
||||
"mapbox_style": "https://legacy.example.com/style.json",
|
||||
"map_renderer": "mapbox",
|
||||
"other_param": "value",
|
||||
}
|
||||
),
|
||||
@@ -237,25 +118,50 @@ def test_migrate_deckgl_slice_copies_style_without_overwriting_renderer():
|
||||
|
||||
assert modified is True
|
||||
params = json.loads(slc.params)
|
||||
assert params["mapbox_style"] == "mapbox://styles/mapbox/dark-v9"
|
||||
assert params["map_renderer"] == "mapbox"
|
||||
assert params["mapbox_style"] == "https://legacy.example.com/style.json"
|
||||
assert params["maplibre_style"] == "https://legacy.example.com/style.json"
|
||||
assert params[DECKGL_MIGRATION_ADDED_FIELDS] == ["maplibre_style"]
|
||||
assert params["viz_type"] == "deck_arc" # viz_type unchanged
|
||||
assert params["other_param"] == "value"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("params", [[], "legacy", 1])
|
||||
def test_migrate_deckgl_slice_ignores_non_object_params(params):
|
||||
def test_migrate_deckgl_slice_open_style():
|
||||
"""All existing deck_* charts get map_renderer='mapbox' for backwards compat."""
|
||||
slc = Slice(
|
||||
slice_name="Test Arc Non-object Params",
|
||||
viz_type="deck_arc",
|
||||
params=json.dumps(params),
|
||||
slice_name="Test Scatter",
|
||||
viz_type="deck_scatter",
|
||||
params=json.dumps(
|
||||
{
|
||||
"viz_type": "deck_scatter",
|
||||
"mapbox_style": "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
modified = _migrate_deckgl_slice(slc)
|
||||
|
||||
assert modified is False
|
||||
assert json.loads(slc.params) == params
|
||||
assert modified is True
|
||||
params = json.loads(slc.params)
|
||||
assert (
|
||||
params["mapbox_style"]
|
||||
== "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
|
||||
)
|
||||
assert params["map_renderer"] == "mapbox"
|
||||
|
||||
|
||||
def test_migrate_deckgl_slice_no_mapbox_style():
|
||||
"""Slices without mapbox_style still get map_renderer='mapbox'."""
|
||||
slc = Slice(
|
||||
slice_name="Test Arc No Style",
|
||||
viz_type="deck_arc",
|
||||
params=json.dumps({"viz_type": "deck_arc", "other_param": "value"}),
|
||||
)
|
||||
|
||||
modified = _migrate_deckgl_slice(slc)
|
||||
|
||||
assert modified is True
|
||||
params = json.loads(slc.params)
|
||||
assert params["map_renderer"] == "mapbox"
|
||||
assert params["other_param"] == "value"
|
||||
|
||||
|
||||
def test_downgrade_deckgl_slice():
|
||||
@@ -267,7 +173,6 @@ def test_downgrade_deckgl_slice():
|
||||
"viz_type": "deck_arc",
|
||||
"mapbox_style": "mapbox://styles/mapbox/dark-v9",
|
||||
"map_renderer": "mapbox",
|
||||
DECKGL_MIGRATION_ADDED_FIELDS: ["map_renderer"],
|
||||
"other_param": "value",
|
||||
}
|
||||
),
|
||||
@@ -279,94 +184,4 @@ def test_downgrade_deckgl_slice():
|
||||
params = json.loads(slc.params)
|
||||
assert params["mapbox_style"] == "mapbox://styles/mapbox/dark-v9"
|
||||
assert "map_renderer" not in params
|
||||
assert DECKGL_MIGRATION_ADDED_FIELDS not in params
|
||||
assert params["other_param"] == "value"
|
||||
|
||||
|
||||
def test_downgrade_deckgl_slice_removes_copied_maplibre_style():
|
||||
slc = Slice(
|
||||
slice_name="Test Arc Open Style",
|
||||
viz_type="deck_arc",
|
||||
params=json.dumps(
|
||||
{
|
||||
"viz_type": "deck_arc",
|
||||
"mapbox_style": "https://legacy.example.com/style.json",
|
||||
"maplibre_style": "https://legacy.example.com/style.json",
|
||||
"map_renderer": "maplibre",
|
||||
DECKGL_MIGRATION_ADDED_FIELDS: ["maplibre_style", "map_renderer"],
|
||||
"other_param": "value",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
modified = _downgrade_deckgl_slice(slc)
|
||||
|
||||
assert modified is True
|
||||
params = json.loads(slc.params)
|
||||
assert params["mapbox_style"] == "https://legacy.example.com/style.json"
|
||||
assert "maplibre_style" not in params
|
||||
assert "map_renderer" not in params
|
||||
assert DECKGL_MIGRATION_ADDED_FIELDS not in params
|
||||
assert params["other_param"] == "value"
|
||||
|
||||
|
||||
def test_downgrade_deckgl_slice_preserves_distinct_maplibre_style():
|
||||
slc = Slice(
|
||||
slice_name="Test Arc Existing MapLibre Style",
|
||||
viz_type="deck_arc",
|
||||
params=json.dumps(
|
||||
{
|
||||
"viz_type": "deck_arc",
|
||||
"mapbox_style": "https://legacy.example.com/style.json",
|
||||
"maplibre_style": "https://saved.example.com/style.json",
|
||||
"other_param": "value",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
modified = _downgrade_deckgl_slice(slc)
|
||||
|
||||
assert modified is False
|
||||
params = json.loads(slc.params)
|
||||
assert params["mapbox_style"] == "https://legacy.example.com/style.json"
|
||||
assert params["maplibre_style"] == "https://saved.example.com/style.json"
|
||||
assert params["other_param"] == "value"
|
||||
|
||||
|
||||
def test_downgrade_deckgl_slice_preserves_unmarked_renderer_and_maplibre_style():
|
||||
slc = Slice(
|
||||
slice_name="Test Arc Existing Fields",
|
||||
viz_type="deck_arc",
|
||||
params=json.dumps(
|
||||
{
|
||||
"viz_type": "deck_arc",
|
||||
"mapbox_style": "https://legacy.example.com/style.json",
|
||||
"maplibre_style": "https://legacy.example.com/style.json",
|
||||
"map_renderer": "maplibre",
|
||||
"other_param": "value",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
modified = _downgrade_deckgl_slice(slc)
|
||||
|
||||
assert modified is False
|
||||
params = json.loads(slc.params)
|
||||
assert params["mapbox_style"] == "https://legacy.example.com/style.json"
|
||||
assert params["maplibre_style"] == "https://legacy.example.com/style.json"
|
||||
assert params["map_renderer"] == "maplibre"
|
||||
assert params["other_param"] == "value"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("params", [[], "legacy", 1])
|
||||
def test_downgrade_deckgl_slice_ignores_non_object_params(params):
|
||||
slc = Slice(
|
||||
slice_name="Test Arc Non-object Params",
|
||||
viz_type="deck_arc",
|
||||
params=json.dumps(params),
|
||||
)
|
||||
|
||||
modified = _downgrade_deckgl_slice(slc)
|
||||
|
||||
assert modified is False
|
||||
assert json.loads(slc.params) == params
|
||||
|
||||
@@ -112,35 +112,6 @@ class TestQueryEstimationCommand(SupersetTestCase):
|
||||
result = command.run()
|
||||
assert result == payload
|
||||
|
||||
@patch("superset.commands.sql_lab.estimate.is_feature_enabled", return_value=True)
|
||||
def test_apply_sql_security_rls_does_not_pollute_session(
|
||||
self, mock_is_feature_enabled: Mock
|
||||
) -> None:
|
||||
"""Regression test for the RLS schema-resolution probe Query.
|
||||
|
||||
``_apply_sql_security`` builds a transient ``Query`` so the engine spec
|
||||
can resolve the effective per-query schema. Because the ``database``
|
||||
backref cascades ``all, delete-orphan``, that transient joins the
|
||||
session; if it isn't expunged, the very next ``apply_rls`` call issues
|
||||
its own ``db.session`` query, autoflush fires, and the probe — whose
|
||||
``client_id`` column is ``nullable=False`` — raises ``IntegrityError``.
|
||||
A mocked session (as in the unit tests) hides this entirely, so exercise
|
||||
the real session and real ``apply_rls`` here with ``RLS_IN_SQLLAB`` on.
|
||||
"""
|
||||
database = get_example_database()
|
||||
params = {"database_id": database.id, "sql": "SELECT * FROM some_table"}
|
||||
schema = EstimateQueryCostSchema()
|
||||
data: EstimateQueryCostSchema = schema.dump(params)
|
||||
command = estimate.QueryEstimationCommand(data)
|
||||
command._database = database
|
||||
|
||||
with override_user(self.get_user("admin")):
|
||||
# Must not raise IntegrityError from an autoflushed probe Query.
|
||||
command._apply_sql_security("SELECT * FROM some_table")
|
||||
|
||||
# And no transient probe Query may be left pending in the session.
|
||||
assert not any(isinstance(obj, Query) for obj in db.session.new)
|
||||
|
||||
|
||||
class TestSqlResultExportCommand(SupersetTestCase):
|
||||
@pytest.fixture
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
# under the License.
|
||||
"""Unit tests for resource-level authorization in QueryEstimationCommand."""
|
||||
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -144,214 +143,3 @@ def test_raise_for_access_called_with_correct_database(
|
||||
|
||||
call_kwargs = mock_security_manager.raise_for_access.call_args.kwargs
|
||||
assert call_kwargs["database"] is mock_database
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQL security controls applied on the estimate path (parity with executor)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_command_with_db(
|
||||
sql: str, *, allow_dml: bool = False, engine: str = "postgresql"
|
||||
) -> QueryEstimationCommand:
|
||||
command = QueryEstimationCommand(_make_params(sql=sql))
|
||||
command._database = MagicMock()
|
||||
command._database.db_engine_spec.engine = engine
|
||||
command._database.allow_dml = allow_dml
|
||||
command._catalog = None
|
||||
command._schema = ""
|
||||
return command
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.estimate.app")
|
||||
def test_apply_sql_security_blocks_dml_when_not_allowed(mock_app: MagicMock) -> None:
|
||||
mock_app.config = {"DISALLOWED_SQL_FUNCTIONS": {}, "DISALLOWED_SQL_TABLES": {}}
|
||||
from superset.exceptions import SupersetDMLNotAllowedException
|
||||
|
||||
command = _make_command_with_db("INSERT INTO t VALUES (1)", allow_dml=False)
|
||||
with pytest.raises(SupersetDMLNotAllowedException):
|
||||
command._apply_sql_security("INSERT INTO t VALUES (1)")
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.estimate.app")
|
||||
def test_apply_sql_security_allows_dml_when_enabled(mock_app: MagicMock) -> None:
|
||||
mock_app.config = {"DISALLOWED_SQL_FUNCTIONS": {}, "DISALLOWED_SQL_TABLES": {}}
|
||||
command = _make_command_with_db("INSERT INTO t VALUES (1)", allow_dml=True)
|
||||
# No exception; SQL returned unchanged (RLS disabled by default).
|
||||
assert command._apply_sql_security("INSERT INTO t VALUES (1)")
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.estimate.app")
|
||||
def test_apply_sql_security_blocks_disallowed_table(mock_app: MagicMock) -> None:
|
||||
mock_app.config = {
|
||||
"DISALLOWED_SQL_FUNCTIONS": {},
|
||||
"DISALLOWED_SQL_TABLES": {"postgresql": {"secrets"}},
|
||||
}
|
||||
from superset.exceptions import SupersetDisallowedSQLTableException
|
||||
|
||||
command = _make_command_with_db("SELECT * FROM secrets", allow_dml=True)
|
||||
with pytest.raises(SupersetDisallowedSQLTableException):
|
||||
command._apply_sql_security("SELECT * FROM secrets")
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.estimate.app")
|
||||
def test_apply_sql_security_blocks_disallowed_function(mock_app: MagicMock) -> None:
|
||||
"""A disallowed function cannot be probed via cost estimation either."""
|
||||
mock_app.config = {
|
||||
"DISALLOWED_SQL_FUNCTIONS": {"postgresql": {"PG_SLEEP"}},
|
||||
"DISALLOWED_SQL_TABLES": {},
|
||||
}
|
||||
from superset.exceptions import SupersetDisallowedSQLFunctionException
|
||||
|
||||
command = _make_command_with_db("SELECT pg_sleep(1)", allow_dml=True)
|
||||
with pytest.raises(SupersetDisallowedSQLFunctionException):
|
||||
command._apply_sql_security("SELECT pg_sleep(1)")
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.estimate.app")
|
||||
def test_apply_sql_security_allows_benign_select(mock_app: MagicMock) -> None:
|
||||
"""A benign statement passes through unchanged (no false positives)."""
|
||||
mock_app.config = {"DISALLOWED_SQL_FUNCTIONS": {}, "DISALLOWED_SQL_TABLES": {}}
|
||||
command = _make_command_with_db("SELECT 1", allow_dml=False)
|
||||
# No disallowed content, no mutation, RLS disabled -> returned unchanged.
|
||||
assert command._apply_sql_security("SELECT 1") == "SELECT 1"
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.estimate.apply_rls")
|
||||
@patch("superset.commands.sql_lab.estimate.Query")
|
||||
@patch("superset.commands.sql_lab.estimate.db")
|
||||
@patch("superset.commands.sql_lab.estimate.is_feature_enabled", return_value=True)
|
||||
@patch("superset.commands.sql_lab.estimate.app")
|
||||
def test_apply_sql_security_injects_rls_when_enabled(
|
||||
mock_app: MagicMock,
|
||||
mock_is_feature_enabled: MagicMock,
|
||||
mock_db: MagicMock,
|
||||
mock_query: MagicMock,
|
||||
mock_apply_rls: MagicMock,
|
||||
) -> None:
|
||||
"""With RLS_IN_SQLLAB enabled, RLS predicates are applied per statement so
|
||||
the estimate reflects the constrained query the user could actually run."""
|
||||
mock_app.config = {"DISALLOWED_SQL_FUNCTIONS": {}, "DISALLOWED_SQL_TABLES": {}}
|
||||
command = _make_command_with_db("SELECT * FROM t", allow_dml=False)
|
||||
|
||||
result = command._apply_sql_security("SELECT * FROM t")
|
||||
|
||||
mock_is_feature_enabled.assert_called_with("RLS_IN_SQLLAB")
|
||||
mock_apply_rls.assert_called_once()
|
||||
# The transient probe Query is expunged so its (deliberately incomplete)
|
||||
# row can't autoflush into the session when apply_rls queries below.
|
||||
mock_db.session.expunge.assert_called_once_with(mock_query.return_value)
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.estimate.Query")
|
||||
@patch("superset.commands.sql_lab.estimate.db")
|
||||
@patch("superset.commands.sql_lab.estimate.apply_rls")
|
||||
@patch("superset.commands.sql_lab.estimate.is_feature_enabled", return_value=True)
|
||||
@patch("superset.commands.sql_lab.estimate.app")
|
||||
def test_apply_sql_security_resolves_default_schema_for_rls(
|
||||
mock_app: MagicMock,
|
||||
mock_is_feature_enabled: MagicMock,
|
||||
mock_apply_rls: MagicMock,
|
||||
mock_db: MagicMock,
|
||||
mock_query: MagicMock,
|
||||
) -> None:
|
||||
"""When no catalog/schema is supplied, RLS must be applied against the
|
||||
database's *resolved* default catalog/schema — mirroring the execution path
|
||||
(``SQLExecutor`` / ``sql_lab.execute_sql_statements``). Passing the raw
|
||||
``""``/``None`` would let unqualified tables dodge RLS predicates that the
|
||||
real query enforces, defeating the security parity goal of this command.
|
||||
"""
|
||||
mock_app.config = {"DISALLOWED_SQL_FUNCTIONS": {}, "DISALLOWED_SQL_TABLES": {}}
|
||||
command = _make_command_with_db("SELECT * FROM t", allow_dml=False)
|
||||
database = cast(MagicMock, command._database)
|
||||
# Caller passed nothing: schema is "" and catalog is None.
|
||||
command._schema = ""
|
||||
command._catalog = None
|
||||
database.get_default_catalog.return_value = "default_catalog"
|
||||
database.get_default_schema_for_query.return_value = "public"
|
||||
|
||||
command._apply_sql_security("SELECT * FROM t")
|
||||
|
||||
# Default catalog/schema are resolved before injection, in the same order
|
||||
# as the executor (catalog first, then schema derived per-query). The schema
|
||||
# goes through ``get_default_schema_for_query`` so engine-specific per-query
|
||||
# security gates (e.g. the Postgres ``search_path`` check) run as well.
|
||||
database.get_default_catalog.assert_called_once_with()
|
||||
database.get_default_schema_for_query.assert_called_once()
|
||||
|
||||
# RLS is applied with the *resolved* values, never the raw ""/None.
|
||||
# apply_rls(database, catalog, schema, statement)
|
||||
call_args = mock_apply_rls.call_args.args
|
||||
assert call_args[1] == "default_catalog"
|
||||
assert call_args[2] == "public"
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.estimate.Query")
|
||||
@patch("superset.commands.sql_lab.estimate.db")
|
||||
@patch("superset.commands.sql_lab.estimate.apply_rls")
|
||||
@patch("superset.commands.sql_lab.estimate.is_feature_enabled", return_value=True)
|
||||
@patch("superset.commands.sql_lab.estimate.app")
|
||||
def test_apply_sql_security_respects_explicit_catalog_schema(
|
||||
mock_app: MagicMock,
|
||||
mock_is_feature_enabled: MagicMock,
|
||||
mock_apply_rls: MagicMock,
|
||||
mock_db: MagicMock,
|
||||
mock_query: MagicMock,
|
||||
) -> None:
|
||||
"""An explicitly supplied catalog short-circuits default-catalog resolution,
|
||||
and the explicit schema wins as the RLS target — but the schema resolver
|
||||
``get_default_schema_for_query`` is still invoked so the engine's per-query
|
||||
security gate runs even when a schema is pinned (parity with the executor,
|
||||
which calls it unconditionally)."""
|
||||
mock_app.config = {"DISALLOWED_SQL_FUNCTIONS": {}, "DISALLOWED_SQL_TABLES": {}}
|
||||
command = _make_command_with_db("SELECT * FROM t", allow_dml=False)
|
||||
database = cast(MagicMock, command._database)
|
||||
command._catalog = "my_catalog"
|
||||
command._schema = "my_schema"
|
||||
|
||||
command._apply_sql_security("SELECT * FROM t")
|
||||
|
||||
# Explicit catalog wins, so the default-catalog lookup is skipped...
|
||||
database.get_default_catalog.assert_not_called()
|
||||
# ...but the schema gate must run even when a schema is pinned, otherwise an
|
||||
# explicit-schema estimate could smuggle a ``SET search_path`` past the gate
|
||||
# the executor enforces.
|
||||
database.get_default_schema_for_query.assert_called_once()
|
||||
call_args = mock_apply_rls.call_args.args
|
||||
assert call_args[1] == "my_catalog"
|
||||
assert call_args[2] == "my_schema"
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.estimate.Query")
|
||||
@patch("superset.commands.sql_lab.estimate.db")
|
||||
@patch("superset.commands.sql_lab.estimate.apply_rls")
|
||||
@patch("superset.commands.sql_lab.estimate.is_feature_enabled", return_value=True)
|
||||
@patch("superset.commands.sql_lab.estimate.app")
|
||||
def test_apply_sql_security_propagates_engine_schema_gate(
|
||||
mock_app: MagicMock,
|
||||
mock_is_feature_enabled: MagicMock,
|
||||
mock_apply_rls: MagicMock,
|
||||
mock_db: MagicMock,
|
||||
mock_query: MagicMock,
|
||||
) -> None:
|
||||
"""Default-schema resolution goes through ``get_default_schema_for_query``,
|
||||
so an engine-specific per-query security gate (e.g. the Postgres
|
||||
``search_path`` check that rejects ``SET search_path = ...``) is enforced on
|
||||
the estimate path too, rather than being silently bypassed.
|
||||
"""
|
||||
mock_app.config = {"DISALLOWED_SQL_FUNCTIONS": {}, "DISALLOWED_SQL_TABLES": {}}
|
||||
command = _make_command_with_db(
|
||||
"SET search_path = secret; SELECT * FROM t", allow_dml=True
|
||||
)
|
||||
database = cast(MagicMock, command._database)
|
||||
command._schema = ""
|
||||
command._catalog = None
|
||||
database.get_default_catalog.return_value = "default_catalog"
|
||||
database.get_default_schema_for_query.side_effect = _security_exception()
|
||||
|
||||
with pytest.raises(SupersetSecurityException):
|
||||
command._apply_sql_security("SET search_path = secret; SELECT * FROM t")
|
||||
|
||||
# RLS injection must not happen once the schema gate has rejected the query.
|
||||
mock_apply_rls.assert_not_called()
|
||||
|
||||
@@ -21,7 +21,6 @@ import pytest
|
||||
from marshmallow import ValidationError
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.dashboards.permalink.schemas import DashboardPermalinkStateSchema
|
||||
from superset.dashboards.schemas import (
|
||||
DashboardCopySchema,
|
||||
DashboardDatasetSchema,
|
||||
@@ -163,33 +162,3 @@ def test_dashboard_copy_css_rejects_dangerous_constructs() -> None:
|
||||
}
|
||||
)
|
||||
assert "css" in exc_info.value.messages
|
||||
|
||||
|
||||
def test_permalink_state_schema_accepts_null_in_active_tabs() -> None:
|
||||
"""Regression test for #40934.
|
||||
|
||||
Legacy v5 dashboard exports persist ``null`` entries inside
|
||||
``activeTabs`` (one ``None`` per tab level that has no active child).
|
||||
The permalink schema must accept those entries instead of rejecting
|
||||
the whole payload with ``'activeTabs': {N: ['Field may not be null.']}``.
|
||||
"""
|
||||
schema = DashboardPermalinkStateSchema()
|
||||
loaded = schema.load({"activeTabs": ["TAB-abc", None, "TAB-xyz", None]})
|
||||
assert loaded["activeTabs"] == ["TAB-abc", None, "TAB-xyz", None]
|
||||
|
||||
|
||||
def test_permalink_state_schema_still_accepts_null_active_tabs_list() -> None:
|
||||
"""A ``None`` for the whole ``activeTabs`` list (not just entries) must
|
||||
keep working — this was the only ``allow_none`` path before #40934."""
|
||||
schema = DashboardPermalinkStateSchema()
|
||||
loaded = schema.load({"activeTabs": None})
|
||||
assert loaded["activeTabs"] is None
|
||||
|
||||
|
||||
def test_permalink_state_schema_still_rejects_non_string_entries() -> None:
|
||||
"""Allowing ``None`` entries should NOT widen the type to ``Any`` —
|
||||
non-string entries like ``int`` or ``dict`` must still be rejected."""
|
||||
schema = DashboardPermalinkStateSchema()
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
schema.load({"activeTabs": ["TAB-abc", 42]})
|
||||
assert "activeTabs" in exc_info.value.messages
|
||||
|
||||
@@ -1,81 +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.
|
||||
|
||||
from superset.utils.core import split
|
||||
|
||||
|
||||
def test_split_empty_string():
|
||||
assert list(split("")) == [""]
|
||||
|
||||
|
||||
def test_split_leading_delimiter():
|
||||
assert list(split(" a")) == [
|
||||
"",
|
||||
"a",
|
||||
]
|
||||
|
||||
|
||||
def test_split_trailing_delimiter():
|
||||
assert list(split("a ")) == [
|
||||
"a",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def test_split_only_delimiter():
|
||||
assert list(split(" ")) == [
|
||||
"",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def test_split_nested_parentheses():
|
||||
assert list(
|
||||
split(
|
||||
"a,(b,(c,d))",
|
||||
delimiter=",",
|
||||
)
|
||||
) == [
|
||||
"a",
|
||||
"(b,(c,d))",
|
||||
]
|
||||
|
||||
|
||||
def test_branch_separator_found():
|
||||
assert list(split("a b")) == [
|
||||
"a",
|
||||
"b",
|
||||
]
|
||||
|
||||
|
||||
def test_branch_separator_not_found():
|
||||
assert list(split("ab")) == [
|
||||
"ab",
|
||||
]
|
||||
|
||||
|
||||
def test_branch_parentheses():
|
||||
assert list(split("(a b)")) == [
|
||||
"(a b)",
|
||||
]
|
||||
|
||||
|
||||
def test_branch_escaped_quote():
|
||||
assert list(split(r'"a\"b c" d')) == [
|
||||
r'"a\"b c"',
|
||||
"d",
|
||||
]
|
||||
@@ -69,12 +69,6 @@ def test_common_bootstrap_payload_handles_none_locale(
|
||||
mock_cached.assert_called_once_with(1, None)
|
||||
|
||||
|
||||
def test_default_map_renderer_is_exposed_to_frontend_config() -> None:
|
||||
from superset.views.base import FRONTEND_CONF_KEYS
|
||||
|
||||
assert "DEFAULT_MAP_RENDERER" in FRONTEND_CONF_KEYS
|
||||
|
||||
|
||||
def _extract_language(
|
||||
locale_str: str | None,
|
||||
languages: dict[str, dict[str, object]] | None = None,
|
||||
|
||||
Reference in New Issue
Block a user