Compare commits

..

2 Commits

Author SHA1 Message Date
Evan
de30eed14f chore(helm): bump chart version to 0.17.0
The chart lint requires a version bump when chart files change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:37:33 -07:00
Claude Code
3da2a210c7 fix(helm)!: replace dockerize initContainer with bash TCP wait
Drops `apache/superset:dockerize` from the chart entirely. The five
initContainers that gate startup on Postgres / Redis now run from the
same `apache/superset` image we're already pulling, using bash's
built-in `/dev/tcp/host/port` redirect for the readiness probe — no
external `dockerize`, `nc`, or busybox needed.

A trivy scan of the current published `apache/superset:dockerize`
(image created 2024-05-09, alpine 3.19.1 EOSL) found 3 CRITICAL,
25 HIGH, 71 MEDIUM, and 24 LOW CVEs — 64 of them in the bundled
`dockerize` Go binary itself (stale Go stdlib + golang.org/x/{net,
crypto}); the rest in the alpine base. Rebuilding the image on a
fresher base would just defer the same problem; removing the
dependency eliminates it.

Verified `/bin/bash` 5.2.15 is present in `apache/superset:latest`
and supports the `/dev/tcp` redirect (the image's `/bin/sh` is dash,
which does not — hence the explicit `/bin/bash` invocation).
Rendered the chart with `helm template` and confirmed all five
initContainers (supersetNode, init, supersetWorker,
supersetCeleryBeat, supersetCeleryFlower) emit the expected
bash-based probe and pull the main superset image.

The 120s timeout from `dockerize -timeout 120s` is preserved via a
SECONDS-based deadline in the bash loop. Two-port waits (postgres
+ redis) factor out a small `wait_for` helper to keep the script
readable.

BREAKING CHANGE: chart `values.yaml` no longer defines `initImage`.
Operators who customised `.Values.initImage.repository/tag/pullPolicy`
must remove those overrides — they are silently ignored. Operators
who fully overrode `.Values.supersetNode.initContainers` (etc.) are
unaffected; their override still wins. Chart bumped 0.15.5 → 0.16.0.

Closes #40424

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 16:09:17 -07:00
63 changed files with 978 additions and 3277 deletions

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -24,27 +24,6 @@ assists people when migrating to a new version.
## Next
### Map chart renderer and OpenStreetMap migration behavior
The MapLibre migration for deck.gl charts preserves saved non-Mapbox styles on
the MapLibre-compatible path. Saved styles such as OpenStreetMap, `tile://`
tile templates, generic HTTPS style URLs, and charts without a saved style are
not reclassified as Mapbox during migration and do not require
`MAPBOX_API_KEY` only because of the migration.
Saved true Mapbox styles whose value starts with `mapbox://` remain
Mapbox-backed. If a Superset deployment does not configure `MAPBOX_API_KEY`,
those saved Mapbox charts keep the existing missing-key message instead of
silently falling back to MapLibre or another provider. In Explore, deck.gl and
point-cluster renderer controls preserve saved Mapbox state, but the Mapbox
choice is not available as a new working renderer without a configured key.
The MapLibre style choices include `Streets (OSM)`, backed by
`https://tile.openstreetmap.org/{z}/{x}/{y}.png`. This OpenStreetMap tile
service requires visible `© OpenStreetMap contributors` attribution and should
be used through normal browser map tile requests and caching; it is not intended
for bulk prefetch or offline tile downloads.
### Duration formatter precision
The `DURATION` number formatter now uses `Intl.DurationFormat` for locale-aware output. By default, sub-second fields are omitted, so values that previously displayed fractional seconds with `pretty-ms`, such as `10500` milliseconds rendering as `10.5s`, now render as `10s`.

View File

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

View File

@@ -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

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.16.0](https://img.shields.io/badge/Version-0.16.0-informational?style=flat-square)
![Version: 0.17.0](https://img.shields.io/badge/Version-0.17.0-informational?style=flat-square)
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 |

View File

@@ -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"

View File

@@ -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"]

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

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

View File

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

View File

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

View File

@@ -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',
);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 \

View File

@@ -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": {

View File

@@ -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"
}
}

View File

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

View File

@@ -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:

View File

@@ -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

View File

@@ -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"},

View File

@@ -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

View File

@@ -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",
)

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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",
]

View File

@@ -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,