mirror of
https://github.com/apache/superset.git
synced 2026-06-14 12:09:14 +00:00
Compare commits
41 Commits
tanstack-r
...
fix/smtp-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e6d44897f | ||
|
|
ddbac55f6f | ||
|
|
721c16827d | ||
|
|
01436ed61e | ||
|
|
73ee788c27 | ||
|
|
395d18d67e | ||
|
|
814b72c6f9 | ||
|
|
663b47aa75 | ||
|
|
9938ee273f | ||
|
|
74845eaf0b | ||
|
|
b0d7880ac0 | ||
|
|
058be4b904 | ||
|
|
42d0c4436e | ||
|
|
378473a6fe | ||
|
|
32ae0afcac | ||
|
|
db7e1c67d8 | ||
|
|
6c5ad1e912 | ||
|
|
2b18dc0a5c | ||
|
|
cc2845168d | ||
|
|
97073340cc | ||
|
|
046b1b61b3 | ||
|
|
da9756ef14 | ||
|
|
f79a88c685 | ||
|
|
b1d965932d | ||
|
|
7d046340dc | ||
|
|
aa872cd0a1 | ||
|
|
b2c5a1ecb3 | ||
|
|
6cd9bdee0b | ||
|
|
a8a1d9c17d | ||
|
|
97058d2cf0 | ||
|
|
ef57409209 | ||
|
|
5f06e66cf1 | ||
|
|
11af932099 | ||
|
|
c9c05d8d0a | ||
|
|
0f59705806 | ||
|
|
320965612d | ||
|
|
c3df60c12b | ||
|
|
4f69949c10 | ||
|
|
3380496e9f | ||
|
|
248ccadecd | ||
|
|
cc5a3ddd05 |
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -41,8 +41,8 @@ body:
|
||||
label: Superset version
|
||||
options:
|
||||
- master / latest-dev
|
||||
- "6.1.0"
|
||||
- "6.0.0"
|
||||
- "5.0.0"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
2
.github/workflows/pre-commit.yml
vendored
2
.github/workflows/pre-commit.yml
vendored
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version-file: "superset-frontend/.nvmrc"
|
||||
cache: "npm"
|
||||
cache-dependency-path: "superset-frontend/package-lock.json"
|
||||
|
||||
|
||||
@@ -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:22-trixie-slim AS superset-node-ci
|
||||
FROM --platform=${BUILDPLATFORM} node:24-trixie-slim AS superset-node-ci
|
||||
ARG BUILD_TRANSLATIONS
|
||||
ENV BUILD_TRANSLATIONS=${BUILD_TRANSLATIONS}
|
||||
ARG DEV_MODE="false" # Skip frontend build in dev mode
|
||||
|
||||
37
UPDATING.md
37
UPDATING.md
@@ -24,6 +24,27 @@ 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`.
|
||||
@@ -70,6 +91,22 @@ superset revoke-guest-tokens
|
||||
|
||||
This change is backward compatible. The feature is off by default, and even when enabled nothing is revoked until an admin explicitly bumps the version: the expected version starts at `0`, and tokens minted before this change (which carry no version claim) are treated as version `0`. No database migration is required.
|
||||
|
||||
### Sessions are terminated when an account is disabled
|
||||
|
||||
Disabling a user account (setting `active` to `False`, via the admin UI, REST API, or CLI) now terminates that user's outstanding sessions on their next request, instead of relying on a passive check. This works for both client-side cookie sessions and server-side session stores via a per-user invalidation epoch (`user_attribute.sessions_invalidated_at`, added by a migration). The mechanism is inert for users that were never disabled (NULL epoch), so there is no behavior change for active users. Re-enabling an account and logging in again starts a fresh, valid session. The migration backfills the epoch for accounts that are already disabled at upgrade time, so re-enabling such an account does not revive a session that predates this feature.
|
||||
|
||||
### SMTP server certificate validation enabled by default
|
||||
|
||||
`SMTP_SSL_SERVER_AUTH` now defaults to `True` (previously `False`). With this default, STARTTLS/SSL connections to the configured SMTP server validate the server's TLS certificate against the system trusted CA store. This makes outbound email (alerts and reports) verify the mail server's identity out of the box.
|
||||
|
||||
If your SMTP server presents a self-signed certificate, or a certificate that is not trusted by the system CA store, email delivery may now fail with a certificate verification error. To restore the previous behavior of skipping certificate validation, set the following in `superset_config.py`:
|
||||
|
||||
```python
|
||||
SMTP_SSL_SERVER_AUTH = False
|
||||
```
|
||||
|
||||
The recommended fix is to add the SMTP server's certificate (or its issuing CA) to the system trust store rather than disabling validation.
|
||||
|
||||
### Dataset import validates catalog against the target connection
|
||||
|
||||
Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database.
|
||||
|
||||
@@ -1 +1 @@
|
||||
v22.22.0
|
||||
v24.16.0
|
||||
|
||||
@@ -64,7 +64,7 @@ dependencies = [
|
||||
"holidays>=0.45, <1",
|
||||
"humanize",
|
||||
"isodate",
|
||||
"jsonpath-ng>=1.6.1, <2",
|
||||
"jsonpath-ng>=1.8.0, <2",
|
||||
"Mako>=1.2.2",
|
||||
"markdown>=3.10.2",
|
||||
# marshmallow>=4 has issues: https://github.com/apache/superset/issues/33162
|
||||
@@ -80,7 +80,7 @@ dependencies = [
|
||||
"bottleneck", # recommended performance dependency for pandas, see https://pandas.pydata.org/docs/getting_started/install.html#performance-dependencies-recommended
|
||||
# --------------------------
|
||||
"parsedatetime",
|
||||
"paramiko>=3.4.0",
|
||||
"paramiko>=3.4.0, <4.0", # 4.0 removed DSSKey, still referenced by sshtunnel
|
||||
"pgsanity",
|
||||
"Pillow>=11.0.0, <13",
|
||||
"polyline>=2.0.0, <3.0",
|
||||
@@ -94,7 +94,7 @@ dependencies = [
|
||||
"PyJWT>=2.4.0, <3.0",
|
||||
"redis>=5.0.0, <6.0",
|
||||
"rison>=2.0.0, <3.0",
|
||||
"selenium>=4.14.0, <5.0",
|
||||
"selenium>=4.44.0, <5.0",
|
||||
"shillelagh[gsheetsapi]>=1.4.4, <2.0",
|
||||
"sshtunnel>=0.4.0, <0.5",
|
||||
"simplejson>=3.15.0",
|
||||
@@ -107,7 +107,7 @@ dependencies = [
|
||||
"typing-extensions>=4, <5",
|
||||
"waitress; sys_platform == 'win32'",
|
||||
"watchdog>=6.0.0",
|
||||
"wtforms>=2.3.3, <4",
|
||||
"wtforms>=3.2.2, <4",
|
||||
"wtforms-json",
|
||||
"xlsxwriter>=3.2.9, <3.3",
|
||||
]
|
||||
@@ -121,7 +121,7 @@ bigquery = [
|
||||
"sqlalchemy-bigquery>=1.15.0",
|
||||
"google-cloud-bigquery>=3.10.0",
|
||||
]
|
||||
clickhouse = ["clickhouse-connect>=0.13.0, <2.0"]
|
||||
clickhouse = ["clickhouse-connect>=1.1.1, <2.0"]
|
||||
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
|
||||
crate = ["sqlalchemy-cratedb>=0.41.0, <1"]
|
||||
d1 = [
|
||||
@@ -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",
|
||||
@@ -161,7 +161,7 @@ hive = [
|
||||
"pyhive[hive]>=0.6.5;python_version<'3.11'",
|
||||
"pyhive[hive_pure_sasl]>=0.7.0",
|
||||
"tableschema",
|
||||
"thrift>=0.14.1, <1.0.0",
|
||||
"thrift>=0.23.0, <1.0.0",
|
||||
"thrift_sasl>=0.4.3, < 1.0.0",
|
||||
]
|
||||
impala = ["impyla>0.16.2, <0.23"]
|
||||
@@ -195,7 +195,7 @@ spark = [
|
||||
"pyhive[hive]>=0.6.5;python_version<'3.11'",
|
||||
"pyhive[hive_pure_sasl]>=0.7",
|
||||
"tableschema",
|
||||
"thrift>=0.14.1, <1",
|
||||
"thrift>=0.23.0, <1",
|
||||
]
|
||||
tdengine = [
|
||||
"taospy>=2.7.21",
|
||||
@@ -205,7 +205,7 @@ teradata = ["teradatasql>=16.20.0.23"]
|
||||
thumbnails = [] # deprecated, will be removed in 7.0
|
||||
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
|
||||
netezza = ["nzalchemy>=11.0.2"]
|
||||
starrocks = ["starrocks>=1.0.0"]
|
||||
starrocks = ["starrocks>=1.3.3, <2"]
|
||||
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"]
|
||||
|
||||
@@ -50,7 +50,7 @@ cattrs==25.1.1
|
||||
# via requests-cache
|
||||
celery==5.5.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
certifi==2025.6.15
|
||||
certifi==2026.5.20
|
||||
# via
|
||||
# requests
|
||||
# selenium
|
||||
@@ -194,7 +194,7 @@ jinja2==3.1.6
|
||||
# via
|
||||
# flask
|
||||
# flask-babel
|
||||
jsonpath-ng==1.7.0
|
||||
jsonpath-ng==1.8.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
jsonschema==4.23.0
|
||||
# via
|
||||
@@ -286,8 +286,6 @@ pillow==12.2.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
platformdirs==4.3.8
|
||||
# via requests-cache
|
||||
ply==3.11
|
||||
# via jsonpath-ng
|
||||
polyline==2.0.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
prison==0.2.1
|
||||
@@ -380,7 +378,7 @@ rpds-py==0.25.0
|
||||
# referencing
|
||||
rsa==4.9.1
|
||||
# via google-auth
|
||||
selenium==4.32.0
|
||||
selenium==4.44.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
setuptools==80.9.0
|
||||
# via -r requirements/base.in
|
||||
@@ -423,7 +421,7 @@ sshtunnel==0.4.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
tabulate==0.10.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
trio==0.30.0
|
||||
trio==0.33.0
|
||||
# via
|
||||
# selenium
|
||||
# trio-websocket
|
||||
@@ -480,7 +478,7 @@ wrapt==1.17.2
|
||||
# via deprecated
|
||||
wsproto==1.2.0
|
||||
# via trio-websocket
|
||||
wtforms==3.2.1
|
||||
wtforms==3.2.2
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
|
||||
@@ -112,7 +112,7 @@ celery==5.5.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
certifi==2025.6.15
|
||||
certifi==2026.5.20
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# httpcore
|
||||
@@ -471,7 +471,7 @@ jmespath==1.1.0
|
||||
# via
|
||||
# boto3
|
||||
# botocore
|
||||
jsonpath-ng==1.7.0
|
||||
jsonpath-ng==1.8.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -674,10 +674,6 @@ platformdirs==4.3.8
|
||||
# virtualenv
|
||||
pluggy==1.5.0
|
||||
# via pytest
|
||||
ply==3.11
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# jsonpath-ng
|
||||
polib==1.2.0
|
||||
# via apache-superset
|
||||
polyline==2.0.2
|
||||
@@ -925,7 +921,7 @@ s3transfer==0.16.0
|
||||
# via boto3
|
||||
secretstorage==3.5.0
|
||||
# via keyring
|
||||
selenium==4.32.0
|
||||
selenium==4.44.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -1023,7 +1019,7 @@ tqdm==4.67.1
|
||||
# prophet
|
||||
trino==0.330.0
|
||||
# via apache-superset
|
||||
trio==0.30.0
|
||||
trio==0.33.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# selenium
|
||||
@@ -1125,7 +1121,7 @@ wsproto==1.2.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# trio-websocket
|
||||
wtforms==3.2.1
|
||||
wtforms==3.2.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -1 +1 @@
|
||||
v22.22.0
|
||||
v24.16.0
|
||||
|
||||
@@ -1 +1 @@
|
||||
v22.22.0
|
||||
v24.16.0
|
||||
|
||||
@@ -48,6 +48,7 @@ module.exports = {
|
||||
'@babel/plugin-syntax-dynamic-import',
|
||||
'@babel/plugin-transform-export-namespace-from',
|
||||
['@babel/plugin-transform-class-properties', { loose: true }],
|
||||
'@babel/plugin-transform-class-static-block',
|
||||
['@babel/plugin-transform-optional-chaining', { loose: true }],
|
||||
['@babel/plugin-transform-private-methods', { loose: true }],
|
||||
['@babel/plugin-transform-nullish-coalescing-operator', { loose: true }],
|
||||
|
||||
57
superset-frontend/cypress-base/package-lock.json
generated
57
superset-frontend/cypress-base/package-lock.json
generated
@@ -2058,6 +2058,24 @@
|
||||
"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",
|
||||
@@ -2934,6 +2952,8 @@
|
||||
"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"
|
||||
}
|
||||
@@ -4353,6 +4373,8 @@
|
||||
"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"
|
||||
@@ -5594,7 +5616,9 @@
|
||||
"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"
|
||||
@@ -7756,7 +7780,9 @@
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
|
||||
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/sshpk": {
|
||||
"version": "1.18.0",
|
||||
@@ -10202,8 +10228,23 @@
|
||||
"camelcase": "^5.3.1",
|
||||
"find-up": "^4.1.0",
|
||||
"get-package-type": "^0.1.0",
|
||||
"js-yaml": "^3.13.1",
|
||||
"js-yaml": "4.1.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": {
|
||||
@@ -11006,6 +11047,8 @@
|
||||
"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"
|
||||
}
|
||||
@@ -12051,7 +12094,9 @@
|
||||
"esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"esquery": {
|
||||
"version": "1.4.0",
|
||||
@@ -12953,6 +12998,8 @@
|
||||
"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"
|
||||
@@ -14463,7 +14510,9 @@
|
||||
"sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
|
||||
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"sshpk": {
|
||||
"version": "1.18.0",
|
||||
|
||||
1413
superset-frontend/package-lock.json
generated
1413
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -154,7 +154,6 @@
|
||||
"@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud",
|
||||
"@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl",
|
||||
"@superset-ui/switchboard": "file:./packages/superset-ui-switchboard",
|
||||
"@tanstack/react-router": "^1.170.15",
|
||||
"@types/d3-format": "^3.0.1",
|
||||
"@types/d3-selection": "^3.0.11",
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
@@ -179,7 +178,7 @@
|
||||
"echarts": "^5.6.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.3.5",
|
||||
"fuse.js": "^7.3.0",
|
||||
"fuse.js": "^7.4.1",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.6.0",
|
||||
"geostyler-data": "^1.1.0",
|
||||
@@ -219,6 +218,7 @@
|
||||
"react-redux": "^7.2.9",
|
||||
"react-resize-detector": "^9.1.1",
|
||||
"react-reverse-portal": "^2.3.0",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-search-input": "^0.11.3",
|
||||
"react-split": "^2.0.9",
|
||||
"react-table": "^7.8.0",
|
||||
@@ -261,13 +261,13 @@
|
||||
"@babel/types": "^7.29.7",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/jest": "^11.14.2",
|
||||
"@formatjs/intl-durationformat": "^0.10.3",
|
||||
"@formatjs/intl-durationformat": "^0.10.13",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.4.1",
|
||||
"@storybook/addon-links": "10.4.1",
|
||||
"@storybook/react-webpack5": "10.4.1",
|
||||
"@storybook/addon-docs": "10.4.2",
|
||||
"@storybook/addon-links": "10.4.2",
|
||||
"@storybook/react-webpack5": "10.4.2",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.40",
|
||||
@@ -289,6 +289,7 @@
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
"@types/react-redux": "^7.1.10",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/redux-localstorage": "^1.0.8",
|
||||
@@ -324,7 +325,7 @@
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.4",
|
||||
"eslint-plugin-storybook": "10.4.1",
|
||||
"eslint-plugin-storybook": "10.4.2",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -354,7 +355,7 @@
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.4.1",
|
||||
"storybook": "10.4.2",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
@@ -381,8 +382,8 @@
|
||||
"regenerator-runtime": "^0.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.0",
|
||||
"npm": "^10.8.1"
|
||||
"node": "^24.16.0",
|
||||
"npm": "^11.13.0"
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "$uuid",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Disposable } from '../common';
|
||||
import { Disposable, Event } from '../common';
|
||||
|
||||
/**
|
||||
* Represents a menu item that links a view to a command.
|
||||
@@ -102,3 +102,37 @@ export declare function registerMenuItem(
|
||||
* ```
|
||||
*/
|
||||
export declare function getMenu(location: string): Menu | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when a menu item is registered.
|
||||
*/
|
||||
export interface MenuItemRegisteredEvent {
|
||||
/** The menu item that was registered. */
|
||||
item: MenuItem;
|
||||
/** The location where the item was registered. */
|
||||
location: string;
|
||||
/** The group the item was placed in. */
|
||||
group: 'primary' | 'secondary' | 'context';
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a menu item is unregistered.
|
||||
*/
|
||||
export interface MenuItemUnregisteredEvent {
|
||||
/** The menu item that was unregistered. */
|
||||
item: MenuItem;
|
||||
/** The location where the item was registered. */
|
||||
location: string;
|
||||
/** The group the item was placed in. */
|
||||
group: 'primary' | 'secondary' | 'context';
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a menu item is registered.
|
||||
*/
|
||||
export declare const onDidRegisterMenuItem: Event<MenuItemRegisteredEvent>;
|
||||
|
||||
/**
|
||||
* Event fired when a menu item is unregistered.
|
||||
*/
|
||||
export declare const onDidUnregisterMenuItem: Event<MenuItemUnregisteredEvent>;
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import { Disposable } from '../common';
|
||||
import { Disposable, Event } from '../common';
|
||||
|
||||
/**
|
||||
* Represents a contributed view in the application.
|
||||
@@ -88,3 +88,33 @@ export declare function registerView(
|
||||
* ```
|
||||
*/
|
||||
export declare function getViews(location: string): View[] | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when a view is registered.
|
||||
*/
|
||||
export interface ViewRegisteredEvent {
|
||||
/** The descriptor of the view that was registered. */
|
||||
view: View;
|
||||
/** The location where the view was registered. */
|
||||
location: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a view is unregistered.
|
||||
*/
|
||||
export interface ViewUnregisteredEvent {
|
||||
/** The descriptor of the view that was unregistered. */
|
||||
view: View;
|
||||
/** The location where the view was registered. */
|
||||
location: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a view is registered.
|
||||
*/
|
||||
export declare const onDidRegisterView: Event<ViewRegisteredEvent>;
|
||||
|
||||
/**
|
||||
* Event fired when a view is unregistered.
|
||||
*/
|
||||
export declare const onDidUnregisterView: Event<ViewUnregisteredEvent>;
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.7",
|
||||
"dompurify": "^3.4.8",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"jed": "^1.1.1",
|
||||
|
||||
@@ -72,6 +72,7 @@ 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', () => {
|
||||
@@ -213,6 +214,9 @@ 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', () => {
|
||||
@@ -471,6 +475,51 @@ test('should handle metrics without labels', () => {
|
||||
expect(grid!.colHeaders).toEqual(['count']);
|
||||
});
|
||||
|
||||
test('should set singular metric for singular-metric chart types like Pie', () => {
|
||||
const rowMetricFormData: TestFormData = {
|
||||
viz_type: 'pie',
|
||||
datasource: '1__table',
|
||||
matrixify_enable: true,
|
||||
matrixify_mode_rows: 'metrics',
|
||||
matrixify_rows: [createAdhocMetric('Revenue'), createAdhocMetric('Profit')],
|
||||
};
|
||||
|
||||
const grid = generateMatrixifyGrid(rowMetricFormData);
|
||||
|
||||
expect(grid).not.toBeNull();
|
||||
expect(grid!.cells[0][0]!.formData.metrics).toEqual([
|
||||
createAdhocMetric('Revenue'),
|
||||
]);
|
||||
expect(grid!.cells[0][0]!.formData.metric).toEqual(
|
||||
createAdhocMetric('Revenue'),
|
||||
);
|
||||
expect(grid!.cells[1][0]!.formData.metrics).toEqual([
|
||||
createAdhocMetric('Profit'),
|
||||
]);
|
||||
expect(grid!.cells[1][0]!.formData.metric).toEqual(
|
||||
createAdhocMetric('Profit'),
|
||||
);
|
||||
});
|
||||
|
||||
test('should not overwrite singular metric in dimension-only mode', () => {
|
||||
const dimensionFormData: TestFormData = {
|
||||
viz_type: 'pie',
|
||||
datasource: '1__table',
|
||||
matrixify_enable: true,
|
||||
matrixify_mode_rows: 'dimensions',
|
||||
matrixify_dimension_rows: {
|
||||
dimension: 'country',
|
||||
values: ['USA', 'Canada'],
|
||||
},
|
||||
metric: 'existing_metric',
|
||||
};
|
||||
|
||||
const grid = generateMatrixifyGrid(dimensionFormData);
|
||||
|
||||
expect(grid).not.toBeNull();
|
||||
expect(grid!.cells[0][0]!.formData.metric).toBe('existing_metric');
|
||||
});
|
||||
|
||||
test('should preserve slice_id and dashboardId for embedded dashboard permissions', () => {
|
||||
const formDataWithDashboardContext: TestFormData = {
|
||||
...baseFormData,
|
||||
|
||||
@@ -197,6 +197,7 @@ function generateCellFormData(
|
||||
// If we have metrics from the matrix, use them; otherwise keep original
|
||||
if (metrics.length > 0) {
|
||||
cellFormData.metrics = metrics;
|
||||
cellFormData.metric = metrics[0];
|
||||
}
|
||||
|
||||
return cellFormData;
|
||||
|
||||
@@ -35,3 +35,4 @@ export * from './typedMemo';
|
||||
export * from './html';
|
||||
export * from './tooltip';
|
||||
export * from './merge';
|
||||
export * from './mapStyles';
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
getBootstrapDataFromDocument,
|
||||
getDefaultMapRenderer,
|
||||
getMapProviderMapStyle,
|
||||
getMapboxApiKeyFromBootstrap,
|
||||
getMapRendererOptions,
|
||||
hasMapboxApiKey,
|
||||
isRasterTileTemplate,
|
||||
OSM_TILE_ATTRIBUTION,
|
||||
OSM_TILE_STYLE_URL,
|
||||
resolveMapStyle,
|
||||
} from './mapStyles';
|
||||
|
||||
test('OSM style metadata uses the approved URL and attribution', () => {
|
||||
expect(OSM_TILE_STYLE_URL).toBe(
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(OSM_TILE_ATTRIBUTION).toBe('© OpenStreetMap contributors');
|
||||
});
|
||||
|
||||
test('Mapbox key helpers report absence and presence from bootstrap data', () => {
|
||||
expect(getMapboxApiKeyFromBootstrap({ common: { conf: {} } })).toBe('');
|
||||
expect(hasMapboxApiKey({ common: { conf: {} } })).toBe(false);
|
||||
expect(
|
||||
getMapboxApiKeyFromBootstrap({
|
||||
common: { conf: { MAPBOX_API_KEY: 'pk.test' } },
|
||||
}),
|
||||
).toBe('pk.test');
|
||||
expect(
|
||||
getMapboxApiKeyFromBootstrap({
|
||||
common: { conf: { MAPBOX_API_KEY: ' pk.test ' } },
|
||||
}),
|
||||
).toBe('pk.test');
|
||||
expect(hasMapboxApiKey({ common: { conf: { MAPBOX_API_KEY: ' ' } } })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
hasMapboxApiKey({ common: { conf: { MAPBOX_API_KEY: 'pk.test' } } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('bootstrap data helper parses document data safely', () => {
|
||||
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
|
||||
common: { conf: { MAPBOX_API_KEY: 'pk.document' } },
|
||||
})}'></div>`;
|
||||
|
||||
expect(getBootstrapDataFromDocument()).toEqual({
|
||||
common: { conf: { MAPBOX_API_KEY: 'pk.document' } },
|
||||
});
|
||||
|
||||
document.body.innerHTML = `<div id="app" data-bootstrap='not-json'></div>`;
|
||||
expect(getBootstrapDataFromDocument()).toBeUndefined();
|
||||
|
||||
document.body.innerHTML = '';
|
||||
expect(getBootstrapDataFromDocument()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('bootstrap data helper returns undefined without a document', () => {
|
||||
// jsdom defines `document` as a non-configurable global, so the SSR guard
|
||||
// cannot be exercised by deleting it. Instead, re-evaluate the function's
|
||||
// own source in a scope where `document` is shadowed with undefined. When
|
||||
// running under coverage, the source is istanbul-instrumented and references
|
||||
// its module-scoped counter, so the counter is injected to keep the guard's
|
||||
// execution attributed to mapStyles.ts.
|
||||
const source = getBootstrapDataFromDocument.toString();
|
||||
const counterName = source.match(/cov_\w+/)?.[0] ?? 'unusedCoverageCounter';
|
||||
const coverage = (globalThis as { __coverage__?: Record<string, unknown> })
|
||||
.__coverage__;
|
||||
const coverageEntry =
|
||||
coverage?.[
|
||||
Object.keys(coverage).find(file => file.endsWith('mapStyles.ts')) ?? ''
|
||||
];
|
||||
// eslint-disable-next-line no-new-func
|
||||
const callWithoutDocument = new Function(
|
||||
counterName,
|
||||
'document',
|
||||
`return (${source})();`,
|
||||
);
|
||||
expect(callWithoutDocument(() => coverageEntry, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('renderer options enable Mapbox only when a key is available', () => {
|
||||
expect(getMapRendererOptions({ hasMapboxKey: true })).toEqual([
|
||||
{ value: 'maplibre' },
|
||||
{ value: 'mapbox' },
|
||||
]);
|
||||
expect(getMapRendererOptions({ hasMapboxKey: false })).toEqual([
|
||||
{ value: 'maplibre' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('renderer options preserve saved Mapbox without API-key labels', () => {
|
||||
expect(
|
||||
getMapRendererOptions({ hasMapboxKey: false, currentValue: 'mapbox' }),
|
||||
).toEqual([{ value: 'maplibre' }, { value: 'mapbox', disabled: true }]);
|
||||
});
|
||||
|
||||
test('map provider style helper preserves legacy non-Mapbox styles for MapLibre', () => {
|
||||
expect(
|
||||
getMapProviderMapStyle({
|
||||
mapProvider: 'maplibre',
|
||||
maplibreStyle: undefined,
|
||||
mapboxStyle: OSM_TILE_STYLE_URL,
|
||||
legacyMapStyle: 'https://example.com/fallback-style.json',
|
||||
}),
|
||||
).toEqual({
|
||||
mapProvider: 'maplibre',
|
||||
mapStyle: OSM_TILE_STYLE_URL,
|
||||
});
|
||||
});
|
||||
|
||||
test('map provider style helper does not send Mapbox URLs to MapLibre', () => {
|
||||
expect(
|
||||
getMapProviderMapStyle({
|
||||
mapProvider: 'maplibre',
|
||||
mapboxStyle: 'mapbox://styles/mapbox/dark-v11',
|
||||
legacyMapStyle: 'https://example.com/fallback-style.json',
|
||||
}),
|
||||
).toEqual({
|
||||
mapProvider: 'maplibre',
|
||||
mapStyle: 'https://example.com/fallback-style.json',
|
||||
});
|
||||
});
|
||||
|
||||
test('map provider style helper uses Mapbox style when Mapbox is selected', () => {
|
||||
expect(
|
||||
getMapProviderMapStyle({
|
||||
mapProvider: 'mapbox',
|
||||
mapboxStyle: 'mapbox://styles/mapbox/dark-v11',
|
||||
legacyMapStyle: 'https://example.com/fallback-style.json',
|
||||
}),
|
||||
).toEqual({
|
||||
mapProvider: 'mapbox',
|
||||
mapStyle: 'mapbox://styles/mapbox/dark-v11',
|
||||
});
|
||||
});
|
||||
|
||||
test('default renderer uses configured Mapbox only when a key is available', () => {
|
||||
expect(
|
||||
getDefaultMapRenderer({
|
||||
common: {
|
||||
conf: {
|
||||
DEFAULT_MAP_RENDERER: 'mapbox',
|
||||
MAPBOX_API_KEY: 'pk.test',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe('mapbox');
|
||||
expect(
|
||||
getDefaultMapRenderer({
|
||||
common: { conf: { DEFAULT_MAP_RENDERER: 'mapbox' } },
|
||||
}),
|
||||
).toBe('maplibre');
|
||||
expect(
|
||||
getDefaultMapRenderer({
|
||||
common: {
|
||||
conf: {
|
||||
DEFAULT_MAP_RENDERER: 'invalid',
|
||||
MAPBOX_API_KEY: 'pk.test',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe('maplibre');
|
||||
});
|
||||
|
||||
test('raster tile templates resolve to MapLibre raster style objects with attribution', () => {
|
||||
const style = resolveMapStyle(OSM_TILE_STYLE_URL, 'default-style.json');
|
||||
|
||||
expect(style).toEqual({
|
||||
version: 8,
|
||||
sources: {
|
||||
'osm-raster-tiles': {
|
||||
type: 'raster',
|
||||
tiles: [OSM_TILE_STYLE_URL],
|
||||
tileSize: 256,
|
||||
attribution: OSM_TILE_ATTRIBUTION,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'osm-raster-layer',
|
||||
type: 'raster',
|
||||
source: 'osm-raster-tiles',
|
||||
minzoom: 0,
|
||||
maxzoom: 22,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('tile protocol raster templates are unwrapped before style resolution', () => {
|
||||
const style = resolveMapStyle(
|
||||
`tile://${OSM_TILE_STYLE_URL}`,
|
||||
'default-style.json',
|
||||
);
|
||||
|
||||
expect(typeof style).toBe('object');
|
||||
if (typeof style !== 'string') {
|
||||
expect(style.sources['osm-raster-tiles'].tiles).toEqual([
|
||||
OSM_TILE_STYLE_URL,
|
||||
]);
|
||||
expect(style.sources['osm-raster-tiles'].attribution).toBe(
|
||||
OSM_TILE_ATTRIBUTION,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenStreetMap subdomain raster templates receive OSM attribution', () => {
|
||||
const osmSubdomainTileUrl =
|
||||
'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
const style = resolveMapStyle(
|
||||
`tile://${osmSubdomainTileUrl}`,
|
||||
'default-style.json',
|
||||
);
|
||||
|
||||
expect(typeof style).toBe('object');
|
||||
if (typeof style !== 'string') {
|
||||
expect(style.sources['osm-raster-tiles'].tiles).toEqual([
|
||||
osmSubdomainTileUrl,
|
||||
]);
|
||||
expect(style.sources['osm-raster-tiles'].attribution).toBe(
|
||||
OSM_TILE_ATTRIBUTION,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('custom raster tile templates do not receive OSM attribution', () => {
|
||||
const customTileUrl = 'https://tiles.example.com/{z}/{x}/{y}.png';
|
||||
const style = resolveMapStyle(
|
||||
`tile://${customTileUrl}`,
|
||||
'default-style.json',
|
||||
);
|
||||
|
||||
expect(typeof style).toBe('object');
|
||||
if (typeof style !== 'string') {
|
||||
expect(style.sources['osm-raster-tiles'].tiles).toEqual([customTileUrl]);
|
||||
expect(style.sources['osm-raster-tiles']).not.toHaveProperty('attribution');
|
||||
}
|
||||
});
|
||||
|
||||
test('relative raster tile templates do not receive OSM attribution', () => {
|
||||
const relativeTileUrl = '/tiles/{z}/{x}/{y}.png';
|
||||
const style = resolveMapStyle(relativeTileUrl, 'default-style.json');
|
||||
|
||||
expect(typeof style).toBe('object');
|
||||
if (typeof style !== 'string') {
|
||||
expect(style.sources['osm-raster-tiles'].tiles).toEqual([relativeTileUrl]);
|
||||
expect(style.sources['osm-raster-tiles']).not.toHaveProperty('attribution');
|
||||
}
|
||||
});
|
||||
|
||||
test('lookalike OpenStreetMap hostnames do not receive OSM attribution', () => {
|
||||
const lookalikeTileUrl =
|
||||
'https://openstreetmap.org.example.com/{z}/{x}/{y}.png';
|
||||
const style = resolveMapStyle(
|
||||
`tile://${lookalikeTileUrl}`,
|
||||
'default-style.json',
|
||||
);
|
||||
|
||||
expect(typeof style).toBe('object');
|
||||
if (typeof style !== 'string') {
|
||||
expect(style.sources['osm-raster-tiles'].tiles).toEqual([lookalikeTileUrl]);
|
||||
expect(style.sources['osm-raster-tiles']).not.toHaveProperty('attribution');
|
||||
}
|
||||
});
|
||||
|
||||
test('style JSON URLs pass through without raster wrapping', () => {
|
||||
const styleUrl = 'https://example.com/styles/custom-style.json';
|
||||
|
||||
expect(isRasterTileTemplate(undefined)).toBe(false);
|
||||
expect(isRasterTileTemplate(styleUrl)).toBe(false);
|
||||
expect(resolveMapStyle(styleUrl, 'default-style.json')).toBe(styleUrl);
|
||||
expect(resolveMapStyle(undefined, 'default-style.json')).toBe(
|
||||
'default-style.json',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ test('format milliseconds in human readable format with default options', () =>
|
||||
});
|
||||
test('format seconds in human readable format with default options', () => {
|
||||
const formatter = createDurationFormatter({ multiplier: 1000 });
|
||||
expect(formatter(-0.5)).toBe('-0s');
|
||||
expect(formatter(-0.5)).toBe('0s');
|
||||
expect(formatter(0.5)).toBe('0s');
|
||||
expect(formatter(1)).toBe('1s');
|
||||
expect(formatter(30)).toBe('30s');
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.18.1",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
"dompurify": "^3.4.7",
|
||||
"dompurify": "^3.4.8",
|
||||
"prop-types": "^15.8.1",
|
||||
"urijs": "^1.19.11"
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
const metricsWithCustomBounds = new Set<string>(
|
||||
metricLabels.filter(metricLabel => {
|
||||
const config = columnConfig?.[metricLabel];
|
||||
const hasMax = !!isDefined(config?.radarMetricMaxValue);
|
||||
@@ -358,6 +358,7 @@ export default function transformProps(
|
||||
metricLabels,
|
||||
getDenormalizedSeriesValue,
|
||||
metricsWithCustomBounds,
|
||||
numberFormatter,
|
||||
);
|
||||
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
* 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
|
||||
*/
|
||||
@@ -47,7 +49,7 @@ interface TooltipParams {
|
||||
|
||||
interface TooltipMetricValue {
|
||||
metric: string;
|
||||
value: number;
|
||||
value: number | string;
|
||||
}
|
||||
|
||||
export const renderNormalizedTooltip = (
|
||||
@@ -55,6 +57,7 @@ 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';
|
||||
@@ -70,7 +73,7 @@ export const renderNormalizedTooltip = (
|
||||
|
||||
return {
|
||||
metric,
|
||||
value: originalValue,
|
||||
value: formatter ? formatter(originalValue) : originalValue,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { getNumberFormatter } from '@superset-ui/core';
|
||||
import { renderNormalizedTooltip } from '../../src/Radar/utils';
|
||||
|
||||
describe('renderNormalizedTooltip', () => {
|
||||
const mockGetDenormalizedValue = jest.fn((_, value) => Number(value));
|
||||
const metrics = ['metric1', 'metric2'];
|
||||
const params = {
|
||||
color: 'red',
|
||||
name: 'series1',
|
||||
value: [100, 200],
|
||||
};
|
||||
const metricsWithCustomBounds = new Set<string>();
|
||||
|
||||
test('should render tooltip with formatted values when formatter is provided', () => {
|
||||
const formatter = getNumberFormatter(',.2f');
|
||||
const tooltip = renderNormalizedTooltip(
|
||||
params,
|
||||
metrics,
|
||||
mockGetDenormalizedValue,
|
||||
metricsWithCustomBounds,
|
||||
formatter,
|
||||
);
|
||||
expect(tooltip).toContain(formatter(100));
|
||||
expect(tooltip).toContain(formatter(200));
|
||||
});
|
||||
|
||||
test('should render tooltip with raw values when formatter is not provided', () => {
|
||||
const tooltip = renderNormalizedTooltip(
|
||||
params,
|
||||
metrics,
|
||||
mockGetDenormalizedValue,
|
||||
metricsWithCustomBounds,
|
||||
);
|
||||
expect(tooltip).toContain('100');
|
||||
expect(tooltip).toContain('200');
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,10 @@ 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';
|
||||
@@ -160,7 +164,10 @@ function MapLibre({
|
||||
const clusters = clusterer.getClusters(bbox, Math.round(viewport.zoom));
|
||||
|
||||
const theme = useTheme();
|
||||
const resolvedMapStyle = mapStyle || DEFAULT_MAP_STYLE;
|
||||
const resolvedMapStyle: ResolvedMapStyle =
|
||||
mapProvider === 'mapbox'
|
||||
? mapStyle || DEFAULT_MAP_STYLE
|
||||
: resolveMapStyle(mapStyle, DEFAULT_MAP_STYLE);
|
||||
const mapboxApiKey = mapProvider === 'mapbox' ? getMapboxApiKey() : '';
|
||||
|
||||
if (mapProvider === 'mapbox' && !mapboxApiKey) {
|
||||
|
||||
@@ -19,11 +19,19 @@
|
||||
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;
|
||||
|
||||
@@ -35,6 +43,11 @@ const colorChoices = [
|
||||
['#dc143c', t('Crimson')],
|
||||
['#228b22', t('Forest Green')],
|
||||
];
|
||||
type MapStyleVisibilityProps = {
|
||||
controls?: {
|
||||
map_renderer?: { value?: unknown };
|
||||
};
|
||||
};
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
@@ -109,7 +122,7 @@ const config: ControlPanelConfig = {
|
||||
'Either a numerical column or `Auto`, which scales the point based ' +
|
||||
'on the largest cluster',
|
||||
),
|
||||
mapStateToProps: (state: any) => {
|
||||
mapStateToProps: (state: ControlPanelState) => {
|
||||
const datasourceChoices = columnChoices(state.datasource);
|
||||
const choices: [string, string][] = [['Auto', t('Auto')]];
|
||||
return {
|
||||
@@ -156,7 +169,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: any) => ({
|
||||
mapStateToProps: (state: ControlPanelState) => ({
|
||||
choices: columnChoices(state.datasource),
|
||||
}),
|
||||
},
|
||||
@@ -200,14 +213,17 @@ const config: ControlPanelConfig = {
|
||||
label: t('Map Renderer'),
|
||||
clearable: false,
|
||||
renderTrigger: true,
|
||||
choices: [
|
||||
['maplibre', t('MapLibre (open-source)')],
|
||||
['mapbox', t('Mapbox (API key required)')],
|
||||
],
|
||||
options: getPointClusterMapRendererProps().options,
|
||||
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(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -220,30 +236,13 @@ const config: ControlPanelConfig = {
|
||||
clearable: false,
|
||||
renderTrigger: true,
|
||||
freeForm: true,
|
||||
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)'),
|
||||
],
|
||||
],
|
||||
choices: POINT_CLUSTER_MAPLIBRE_STYLE_CHOICES,
|
||||
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 }: any) =>
|
||||
visibility: ({ controls }: MapStyleVisibilityProps) =>
|
||||
controls?.map_renderer?.value !== 'mapbox',
|
||||
},
|
||||
},
|
||||
@@ -272,7 +271,7 @@ const config: ControlPanelConfig = {
|
||||
description: t(
|
||||
'Base layer map style. Accepts a Mapbox style URL (mapbox://styles/...).',
|
||||
),
|
||||
visibility: ({ controls }: any) =>
|
||||
visibility: ({ controls }: MapStyleVisibilityProps) =>
|
||||
controls?.map_renderer?.value === 'mapbox',
|
||||
},
|
||||
},
|
||||
@@ -387,7 +386,7 @@ const config: ControlPanelConfig = {
|
||||
),
|
||||
},
|
||||
},
|
||||
formDataOverrides: (formData: any) => ({
|
||||
formDataOverrides: (formData: QueryFormData) => ({
|
||||
...formData,
|
||||
groupby: getStandardizedControls().popAllColumns(),
|
||||
}),
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import Supercluster, {
|
||||
type Options as SuperclusterOptions,
|
||||
} from 'supercluster';
|
||||
import { ChartProps } from '@superset-ui/core';
|
||||
import { ChartProps, getMapProviderMapStyle } 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,6 +152,7 @@ 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,
|
||||
@@ -242,6 +243,12 @@ 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,
|
||||
@@ -251,11 +258,8 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
clusterer,
|
||||
globalOpacity: Math.min(1, Math.max(0, toFiniteNumber(globalOpacity) ?? 1)),
|
||||
hasCustomMetric,
|
||||
mapProvider,
|
||||
mapStyle:
|
||||
mapProvider === 'mapbox'
|
||||
? (mapboxStyle as string)
|
||||
: (maplibreStyle as string),
|
||||
mapProvider: selectedMap.mapProvider,
|
||||
mapStyle: selectedMap.mapStyle,
|
||||
onViewportChange({
|
||||
latitude,
|
||||
longitude,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
getMapRendererOptions,
|
||||
OSM_TILE_STYLE_URL,
|
||||
type MapRendererOption,
|
||||
type MapProvider,
|
||||
} from '@superset-ui/core/utils/mapStyles';
|
||||
import { hasMapboxApiKey } from './mapbox';
|
||||
|
||||
export const POINT_CLUSTER_MAPLIBRE_STYLE_CHOICES = [
|
||||
['https://tiles.openfreemap.org/styles/liberty', t('Liberty (OpenFreeMap)')],
|
||||
[
|
||||
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||
t('Light (Carto)'),
|
||||
],
|
||||
[
|
||||
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
|
||||
t('Dark (Carto)'),
|
||||
],
|
||||
[
|
||||
'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
|
||||
t('Streets (Carto)'),
|
||||
],
|
||||
[OSM_TILE_STYLE_URL, t('Streets (OSM)')],
|
||||
];
|
||||
|
||||
export function getPointClusterMapRendererProps(currentValue?: MapProvider) {
|
||||
const hasKey = hasMapboxApiKey();
|
||||
return {
|
||||
options: getMapRendererOptions({
|
||||
hasMapboxKey: hasKey,
|
||||
currentValue,
|
||||
}).map((option: MapRendererOption) => ({
|
||||
...option,
|
||||
label:
|
||||
option.value === 'maplibre'
|
||||
? t('MapLibre (open-source)')
|
||||
: t('Mapbox (API key required)'),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -17,19 +17,15 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
getMapboxApiKeyFromBootstrap,
|
||||
hasMapboxApiKey as hasBootstrapMapboxApiKey,
|
||||
} from '@superset-ui/core/utils/mapStyles';
|
||||
|
||||
export function getMapboxApiKey(): string {
|
||||
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 '';
|
||||
return getMapboxApiKeyFromBootstrap();
|
||||
}
|
||||
|
||||
export function hasMapboxApiKey(): boolean {
|
||||
return hasBootstrapMapboxApiKey();
|
||||
}
|
||||
|
||||
@@ -18,7 +18,12 @@
|
||||
*/
|
||||
|
||||
import { type ReactNode } from 'react';
|
||||
import { render } from '@testing-library/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';
|
||||
|
||||
// Capture the most recent viewport props passed to the Map component
|
||||
let lastMapProps: Record<string, unknown> = {};
|
||||
@@ -91,6 +96,7 @@ const defaultProps = {
|
||||
|
||||
beforeEach(() => {
|
||||
lastMapProps = {};
|
||||
document.body.innerHTML = '';
|
||||
jest.clearAllMocks();
|
||||
mockFitBounds.mockImplementation(
|
||||
(
|
||||
@@ -183,6 +189,65 @@ test('passes globalOpacity to ScatterPlotOverlay', () => {
|
||||
expect(overlay!.getAttribute('data-opacity')).toBe('0.5');
|
||||
});
|
||||
|
||||
test('converts OSM raster tile templates into MapLibre style objects', () => {
|
||||
render(<MapLibre {...defaultProps} mapStyle={OSM_TILE_STYLE_URL} />);
|
||||
|
||||
expect(lastMapProps.mapStyle).toEqual({
|
||||
version: 8,
|
||||
sources: {
|
||||
'osm-raster-tiles': {
|
||||
type: 'raster',
|
||||
tiles: [OSM_TILE_STYLE_URL],
|
||||
tileSize: 256,
|
||||
attribution: OSM_TILE_ATTRIBUTION,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'osm-raster-layer',
|
||||
type: 'raster',
|
||||
source: 'osm-raster-tiles',
|
||||
minzoom: 0,
|
||||
maxzoom: 22,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps the missing Mapbox key signal for saved Mapbox charts', () => {
|
||||
render(
|
||||
<MapLibre
|
||||
{...defaultProps}
|
||||
mapProvider="mapbox"
|
||||
mapStyle="mapbox://styles/mapbox/dark-v11"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Mapbox requires a MAPBOX_API_KEY to be configured on the server.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(lastMapProps.mapStyle).toBeUndefined();
|
||||
});
|
||||
|
||||
test('passes Mapbox styles through when a key exists', () => {
|
||||
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
|
||||
common: { conf: { MAPBOX_API_KEY: 'pk.test' } },
|
||||
})}'></div>`;
|
||||
|
||||
render(
|
||||
<MapLibre
|
||||
{...defaultProps}
|
||||
mapProvider="mapbox"
|
||||
mapStyle="mapbox://styles/mapbox/dark-v11"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastMapProps.mapStyle).toBe('mapbox://styles/mapbox/dark-v11');
|
||||
expect(lastMapProps.mapboxAccessToken).toBe('pk.test');
|
||||
});
|
||||
|
||||
test('handles undefined bounds gracefully', () => {
|
||||
render(<MapLibre {...defaultProps} bounds={undefined} />);
|
||||
expect(lastMapProps.longitude).toBe(0);
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
* 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']>;
|
||||
@@ -54,6 +56,27 @@ 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');
|
||||
@@ -79,3 +102,63 @@ test('opacity control rerenders immediately when changed', () => {
|
||||
expect(opacityControl.config.renderTrigger).toBe(true);
|
||||
expect(opacityControl.config.isFloat).toBe(true);
|
||||
});
|
||||
|
||||
test('MapLibre style choices expose Streets (OSM)', () => {
|
||||
expect(
|
||||
getControl(controlPanel, 'maplibre_style').config.choices,
|
||||
).toContainEqual([OSM_TILE_STYLE_URL, 'Streets (OSM)']);
|
||||
});
|
||||
|
||||
test('map renderer hides Mapbox when no key exists for new selections', () => {
|
||||
setBootstrap({});
|
||||
|
||||
const props = getMapRendererProps('maplibre');
|
||||
|
||||
expect(props.options).toEqual([
|
||||
{ value: 'maplibre', label: 'MapLibre (open-source)' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('map renderer keeps saved Mapbox visible while disabled without a key', () => {
|
||||
setBootstrap({});
|
||||
|
||||
const props = getMapRendererProps('mapbox');
|
||||
|
||||
expect(props.options).toContainEqual({
|
||||
value: 'mapbox',
|
||||
label: 'Mapbox (API key required)',
|
||||
disabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('map renderer enables Mapbox when a key exists', () => {
|
||||
setBootstrap({ MAPBOX_API_KEY: 'pk.test' });
|
||||
|
||||
const props = getMapRendererProps('maplibre');
|
||||
|
||||
expect(props.options).toEqual([
|
||||
{ value: 'maplibre', label: 'MapLibre (open-source)' },
|
||||
{ value: 'mapbox', label: 'Mapbox (API key required)' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('map renderer keeps the original explanatory description', () => {
|
||||
expect(getControl(controlPanel, 'map_renderer').config.description).toBe(
|
||||
'MapLibre is open-source and requires no API key. Mapbox requires MAPBOX_API_KEY to be configured on the server.',
|
||||
);
|
||||
});
|
||||
|
||||
test('map renderer defaults to configured Mapbox when a key exists', () => {
|
||||
setBootstrap({
|
||||
DEFAULT_MAP_RENDERER: 'mapbox',
|
||||
MAPBOX_API_KEY: 'pk.test',
|
||||
});
|
||||
|
||||
expect(getMapRendererProps('maplibre').default).toBe('mapbox');
|
||||
});
|
||||
|
||||
test('map renderer falls back from configured Mapbox default without a key', () => {
|
||||
setBootstrap({ DEFAULT_MAP_RENDERER: 'mapbox' });
|
||||
|
||||
expect(getMapRendererProps('maplibre').default).toBe('maplibre');
|
||||
});
|
||||
|
||||
@@ -34,6 +34,8 @@ import transformProps from '../src/transformProps';
|
||||
|
||||
type TransformPropsResult = {
|
||||
globalOpacity?: number;
|
||||
mapProvider?: string;
|
||||
mapStyle?: string;
|
||||
onViewportChange?: (viewport: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
@@ -215,6 +217,41 @@ 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({
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@deck.gl/extensions": "~9.2.9",
|
||||
"@deck.gl/geo-layers": "~9.2.5",
|
||||
"@deck.gl/layers": "~9.2.5",
|
||||
"@deck.gl/mapbox": "~9.3.2",
|
||||
"@deck.gl/mapbox": "~9.3.3",
|
||||
"@deck.gl/mesh-layers": "~9.2.5",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
JsonValue,
|
||||
QueryFormData,
|
||||
SetDataMaskHook,
|
||||
getMapProviderMapStyle,
|
||||
} from '@superset-ui/core';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import Legend from './components/Legend';
|
||||
@@ -318,6 +319,12 @@ 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' }}>
|
||||
@@ -326,14 +333,8 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
|
||||
viewport={viewport}
|
||||
layers={getLayers()}
|
||||
setControlValue={props.setControlValue}
|
||||
mapStyle={
|
||||
props.formData.map_renderer === 'mapbox'
|
||||
? props.formData.mapbox_style
|
||||
: props.formData.maplibre_style
|
||||
}
|
||||
mapProvider={
|
||||
props.formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'
|
||||
}
|
||||
mapStyle={selectedMap.mapStyle}
|
||||
mapProvider={selectedMap.mapProvider}
|
||||
mapboxApiKey={getMapboxApiKey()}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { ComponentProps, createRef, ReactNode } from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
|
||||
import {
|
||||
OSM_TILE_ATTRIBUTION,
|
||||
OSM_TILE_STYLE_URL,
|
||||
} from '@superset-ui/core/utils/mapStyles';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { DeckGLContainer, DeckGLContainerHandle } from './DeckGLContainer';
|
||||
|
||||
jest.mock('react-map-gl/maplibre', () => ({
|
||||
Map: ({
|
||||
children,
|
||||
mapStyle,
|
||||
onMove,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
mapStyle: unknown;
|
||||
onMove: (evt: { viewState: Record<string, number> }) => void;
|
||||
}) => (
|
||||
<div data-test="maplibre-map" data-map-style={JSON.stringify(mapStyle)}>
|
||||
<button
|
||||
type="button"
|
||||
data-test="maplibre-move"
|
||||
onClick={() =>
|
||||
onMove({ viewState: { longitude: 1, latitude: 2, zoom: 3 } })
|
||||
}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('react-map-gl/mapbox', () => ({
|
||||
Map: ({ children, mapStyle }: { children: ReactNode; mapStyle: unknown }) => (
|
||||
<div data-test="mapbox-map" data-map-style={JSON.stringify(mapStyle)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('mapbox-gl', () => ({ accessToken: '' }));
|
||||
|
||||
jest.mock(
|
||||
'./components/DeckGLOverlayMapLibre',
|
||||
() =>
|
||||
({ layers }: { layers: unknown[] }) => (
|
||||
<div data-test="maplibre-overlay" data-layers-count={layers.length} />
|
||||
),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'./components/DeckGLOverlayMapbox',
|
||||
() =>
|
||||
({ layers }: { layers: unknown[] }) => (
|
||||
<div data-test="mapbox-overlay" data-layers-count={layers.length} />
|
||||
),
|
||||
);
|
||||
|
||||
jest.mock('./components/Tooltip', () => ({
|
||||
__esModule: true,
|
||||
default: ({ variant = 'default' }: { variant?: 'default' | 'custom' }) => (
|
||||
<div data-test={`tooltip-${variant}`} />
|
||||
),
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
viewport: { longitude: 0, latitude: 0, zoom: 1, bearing: 0, pitch: 0 },
|
||||
width: 800,
|
||||
height: 600,
|
||||
layers: [],
|
||||
};
|
||||
|
||||
const renderContainer = (
|
||||
props: Partial<ComponentProps<typeof DeckGLContainer>>,
|
||||
) =>
|
||||
render(
|
||||
<ThemeProvider theme={supersetTheme}>
|
||||
<DeckGLContainer {...baseProps} {...props} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('DeckGLContainer converts OSM raster tile templates into MapLibre style objects', () => {
|
||||
renderContainer({ mapProvider: 'maplibre', mapStyle: OSM_TILE_STYLE_URL });
|
||||
|
||||
const style = JSON.parse(
|
||||
screen.getByTestId('maplibre-map').getAttribute('data-map-style') || '{}',
|
||||
);
|
||||
|
||||
expect(style.sources['osm-raster-tiles']).toEqual({
|
||||
type: 'raster',
|
||||
tiles: [OSM_TILE_STYLE_URL],
|
||||
tileSize: 256,
|
||||
attribution: OSM_TILE_ATTRIBUTION,
|
||||
});
|
||||
expect(style.layers[0]).toMatchObject({
|
||||
id: 'osm-raster-layer',
|
||||
type: 'raster',
|
||||
source: 'osm-raster-tiles',
|
||||
});
|
||||
});
|
||||
|
||||
test('DeckGLContainer passes style JSON URLs through to MapLibre', () => {
|
||||
const styleUrl = 'https://example.com/styles/custom-style.json';
|
||||
|
||||
renderContainer({ mapProvider: 'maplibre', mapStyle: styleUrl });
|
||||
|
||||
expect(screen.getByTestId('maplibre-map')).toHaveAttribute(
|
||||
'data-map-style',
|
||||
JSON.stringify(styleUrl),
|
||||
);
|
||||
});
|
||||
|
||||
test('DeckGLContainer keeps the missing Mapbox key signal for saved Mapbox charts', () => {
|
||||
renderContainer({
|
||||
mapProvider: 'mapbox',
|
||||
mapStyle: 'mapbox://styles/mapbox/dark-v9',
|
||||
mapboxApiKey: '',
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Mapbox requires a MAPBOX_API_KEY to be configured on the server.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('maplibre-map')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('mapbox-map')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('DeckGLContainer passes Mapbox styles through when a key exists', () => {
|
||||
renderContainer({
|
||||
mapProvider: 'mapbox',
|
||||
mapStyle: 'mapbox://styles/mapbox/dark-v9',
|
||||
mapboxApiKey: 'pk.test',
|
||||
});
|
||||
|
||||
expect(mapboxgl.accessToken).toBe('pk.test');
|
||||
expect(screen.getByTestId('mapbox-map')).toHaveAttribute(
|
||||
'data-map-style',
|
||||
JSON.stringify('mapbox://styles/mapbox/dark-v9'),
|
||||
);
|
||||
});
|
||||
|
||||
test('DeckGLContainer supports layer factories for MapLibre overlays', () => {
|
||||
const layer = { id: 'layer-1' } as unknown as Layer;
|
||||
const layerFactory = () => layer;
|
||||
|
||||
renderContainer({ mapProvider: 'maplibre', layers: [layerFactory] });
|
||||
|
||||
expect(screen.getByTestId('maplibre-overlay')).toHaveAttribute(
|
||||
'data-layers-count',
|
||||
'1',
|
||||
);
|
||||
});
|
||||
|
||||
test('DeckGLContainer updates viewport controls after map movement is throttled', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(1000);
|
||||
const setControlValue = jest.fn();
|
||||
|
||||
renderContainer({ mapProvider: 'maplibre', setControlValue });
|
||||
fireEvent.click(screen.getByTestId('maplibre-move'));
|
||||
|
||||
jest.setSystemTime(1301);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
expect(setControlValue).toHaveBeenCalledWith('viewport', {
|
||||
longitude: 1,
|
||||
latitude: 2,
|
||||
zoom: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('DeckGLContainer suppresses the native context menu', () => {
|
||||
renderContainer({ mapProvider: 'maplibre' });
|
||||
|
||||
const event = new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
|
||||
const stopPropagationSpy = jest.spyOn(event, 'stopPropagation');
|
||||
|
||||
screen.getByTestId('maplibre-map').parentElement?.dispatchEvent(event);
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('DeckGLContainer renders default and custom tooltip variants through its ref', () => {
|
||||
const ref = createRef<DeckGLContainerHandle>();
|
||||
|
||||
render(
|
||||
<ThemeProvider theme={supersetTheme}>
|
||||
<DeckGLContainer {...baseProps} mapProvider="maplibre" ref={ref} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
ref.current?.setTooltip({ x: 0, y: 0, content: 'Default tooltip' });
|
||||
});
|
||||
expect(screen.getByTestId('tooltip-default')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
ref.current?.setTooltip({
|
||||
x: 0,
|
||||
y: 0,
|
||||
content: <span data-tooltip-type="custom">Custom tooltip</span>,
|
||||
});
|
||||
});
|
||||
expect(screen.getByTestId('tooltip-custom')).toBeInTheDocument();
|
||||
});
|
||||
@@ -33,6 +33,11 @@ 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';
|
||||
@@ -50,7 +55,7 @@ export type DeckGLContainerProps = {
|
||||
viewport: Viewport;
|
||||
setControlValue?: (control: string, value: JsonValue) => void;
|
||||
mapStyle?: string;
|
||||
mapProvider?: 'maplibre' | 'mapbox';
|
||||
mapProvider?: MapProvider;
|
||||
mapboxApiKey?: string;
|
||||
children?: ReactNode;
|
||||
width: number;
|
||||
@@ -123,7 +128,9 @@ export const DeckGLContainer = memo(
|
||||
const theme = useTheme();
|
||||
const { children = null, height, width } = props;
|
||||
const isMapbox = props.mapProvider === 'mapbox';
|
||||
const mapStyle = props.mapStyle || DEFAULT_MAP_STYLE;
|
||||
const mapStyle: ResolvedMapStyle = isMapbox
|
||||
? props.mapStyle || DEFAULT_MAP_STYLE
|
||||
: resolveMapStyle(props.mapStyle, DEFAULT_MAP_STYLE);
|
||||
|
||||
if (isMapbox && !props.mapboxApiKey) {
|
||||
return (
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
QueryFormData,
|
||||
QueryObjectFilterClause,
|
||||
SupersetClient,
|
||||
getMapProviderMapStyle,
|
||||
usePrevious,
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
@@ -397,6 +398,12 @@ 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}>
|
||||
@@ -404,12 +411,8 @@ const DeckMulti = (props: DeckMultiProps) => {
|
||||
ref={containerRef}
|
||||
viewport={viewport}
|
||||
layers={layers}
|
||||
mapStyle={
|
||||
formData.map_renderer === 'mapbox'
|
||||
? formData.mapbox_style
|
||||
: formData.maplibre_style
|
||||
}
|
||||
mapProvider={formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'}
|
||||
mapStyle={selectedMap.mapStyle}
|
||||
mapProvider={selectedMap.mapProvider}
|
||||
mapboxApiKey={getMapboxApiKey()}
|
||||
setControlValue={setControlValue}
|
||||
onViewportChange={setViewport}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
FilterState,
|
||||
JsonValue,
|
||||
ContextMenuFilters,
|
||||
getMapProviderMapStyle,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
import {
|
||||
@@ -184,6 +185,12 @@ 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' }}>
|
||||
@@ -191,14 +198,8 @@ export function createDeckGLComponent(
|
||||
ref={containerRef}
|
||||
viewport={viewport}
|
||||
layers={layers}
|
||||
mapStyle={
|
||||
formData.map_renderer === 'mapbox'
|
||||
? formData.mapbox_style
|
||||
: formData.maplibre_style
|
||||
}
|
||||
mapProvider={
|
||||
formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'
|
||||
}
|
||||
mapStyle={selectedMap.mapStyle}
|
||||
mapProvider={selectedMap.mapProvider}
|
||||
mapboxApiKey={getMapboxApiKey()}
|
||||
setControlValue={setControlValue}
|
||||
width={width}
|
||||
|
||||
@@ -1,122 +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 { 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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ReactElement } from 'react';
|
||||
import type { ControlPanelSectionConfig } from '@superset-ui/chart-controls';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { render } from '@testing-library/react';
|
||||
import { SqlaFormData } from '@superset-ui/core';
|
||||
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
|
||||
import DeckGLGeoJson, {
|
||||
computeGeoJsonTextOptionsFromJsOutput,
|
||||
computeGeoJsonTextOptionsFromFormData,
|
||||
computeGeoJsonIconOptionsFromJsOutput,
|
||||
computeGeoJsonIconOptionsFromFormData,
|
||||
getPoints,
|
||||
} from './Geojson';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
const mockDeckGLContainerProps: Array<Record<string, unknown>> = [];
|
||||
|
||||
jest.mock('../../DeckGLContainer', () => ({
|
||||
DeckGLContainerStyledWrapper: (props: Record<string, unknown>) => {
|
||||
mockDeckGLContainerProps.push(props);
|
||||
const React = jest.requireActual('react');
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'deckgl-container' },
|
||||
props.children,
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/mapbox', () => ({
|
||||
getMapboxApiKey: () => 'bootstrap-mapbox-key',
|
||||
hasMapboxApiKey: () => true,
|
||||
}));
|
||||
|
||||
jest.mock('react-map-gl/maplibre', () => ({
|
||||
__esModule: true,
|
||||
Map: () => null,
|
||||
useControl: () => null,
|
||||
}));
|
||||
|
||||
test('computeGeoJsonTextOptionsFromJsOutput returns an empty object for non-object input', () => {
|
||||
expect(computeGeoJsonTextOptionsFromJsOutput(null)).toEqual({});
|
||||
expect(computeGeoJsonTextOptionsFromJsOutput(42)).toEqual({});
|
||||
expect(computeGeoJsonTextOptionsFromJsOutput([1, 2, 3])).toEqual({});
|
||||
expect(computeGeoJsonTextOptionsFromJsOutput('string')).toEqual({});
|
||||
});
|
||||
|
||||
test('computeGeoJsonTextOptionsFromJsOutput extracts valid text options from the input object', () => {
|
||||
const input = {
|
||||
getText: 'name',
|
||||
getTextColor: [1, 2, 3, 255],
|
||||
invalidOption: true,
|
||||
};
|
||||
const expectedOutput = {
|
||||
getText: 'name',
|
||||
getTextColor: [1, 2, 3, 255],
|
||||
};
|
||||
expect(computeGeoJsonTextOptionsFromJsOutput(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
test('computeGeoJsonTextOptionsFromFormData computes text options based on form data', () => {
|
||||
const formData: SqlaFormData = {
|
||||
label_property_name: 'name',
|
||||
label_color: { r: 1, g: 2, b: 3, a: 1 },
|
||||
label_size: 123,
|
||||
label_size_unit: 'pixels',
|
||||
datasource: 'test_datasource',
|
||||
viz_type: 'deck_geojson',
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
getText: expect.any(Function),
|
||||
getTextColor: [1, 2, 3, 255],
|
||||
getTextSize: 123,
|
||||
textSizeUnits: 'pixels',
|
||||
};
|
||||
|
||||
const actualOutput = computeGeoJsonTextOptionsFromFormData(formData);
|
||||
expect(actualOutput).toEqual(expectedOutput);
|
||||
|
||||
const sampleFeature = { properties: { name: 'Test' } };
|
||||
expect(actualOutput.getText(sampleFeature)).toBe('Test');
|
||||
});
|
||||
|
||||
test('computeGeoJsonIconOptionsFromJsOutput returns an empty object for non-object input', () => {
|
||||
expect(computeGeoJsonIconOptionsFromJsOutput(null)).toEqual({});
|
||||
expect(computeGeoJsonIconOptionsFromJsOutput(42)).toEqual({});
|
||||
expect(computeGeoJsonIconOptionsFromJsOutput([1, 2, 3])).toEqual({});
|
||||
expect(computeGeoJsonIconOptionsFromJsOutput('string')).toEqual({});
|
||||
});
|
||||
|
||||
test('computeGeoJsonIconOptionsFromJsOutput extracts valid icon options from the input object', () => {
|
||||
const input = {
|
||||
getIcon: 'icon_name',
|
||||
getIconColor: [1, 2, 3, 255],
|
||||
invalidOption: false,
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
getIcon: 'icon_name',
|
||||
getIconColor: [1, 2, 3, 255],
|
||||
};
|
||||
|
||||
expect(computeGeoJsonIconOptionsFromJsOutput(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
test('computeGeoJsonIconOptionsFromFormData computes icon options based on form data', () => {
|
||||
const formData: SqlaFormData = {
|
||||
icon_url: 'https://example.com/icon.png',
|
||||
icon_size: 123,
|
||||
icon_size_unit: 'pixels',
|
||||
datasource: 'test_datasource',
|
||||
viz_type: 'deck_geojson',
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
getIcon: expect.any(Function),
|
||||
getIconSize: 123,
|
||||
iconSizeUnits: 'pixels',
|
||||
};
|
||||
|
||||
const actualOutput = computeGeoJsonIconOptionsFromFormData(formData);
|
||||
expect(actualOutput).toEqual(expectedOutput);
|
||||
|
||||
expect(actualOutput.getIcon()).toEqual({
|
||||
url: 'https://example.com/icon.png',
|
||||
height: 128,
|
||||
width: 128,
|
||||
});
|
||||
});
|
||||
|
||||
test('controlPanel expands Map section so renderer controls are visible', () => {
|
||||
const mapSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section !== null && section.label === 'Map',
|
||||
);
|
||||
|
||||
expect(mapSection).toBeDefined();
|
||||
expect(mapSection?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
test('getPoints skips malformed GeoJSON entries instead of throwing', () => {
|
||||
const features = [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [1, 2] },
|
||||
properties: {},
|
||||
},
|
||||
[[0, 0]],
|
||||
null,
|
||||
] as unknown as Parameters<typeof getPoints>[0];
|
||||
|
||||
expect(getPoints(features)).toEqual([
|
||||
[1, 2],
|
||||
[1, 2],
|
||||
]);
|
||||
expect(getPoints()).toEqual([]);
|
||||
});
|
||||
|
||||
const renderWithTheme = (component: ReactElement) =>
|
||||
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
|
||||
|
||||
const geoJsonProps = {
|
||||
formData: {
|
||||
datasource: 'test_datasource',
|
||||
viz_type: 'deck_geojson',
|
||||
slice_id: 1,
|
||||
autozoom: false,
|
||||
map_style: 'legacy-map-style',
|
||||
extruded: false,
|
||||
filled: true,
|
||||
stroked: true,
|
||||
line_width: 1,
|
||||
line_width_unit: 'pixels',
|
||||
point_radius_scale: 1,
|
||||
enable_labels: false,
|
||||
enable_icons: false,
|
||||
},
|
||||
payload: {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [0, 0] },
|
||||
properties: { name: 'Test point' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
setControlValue: jest.fn(),
|
||||
viewport: { longitude: 0, latitude: 0, zoom: 1 },
|
||||
onAddFilter: jest.fn(),
|
||||
height: 600,
|
||||
width: 800,
|
||||
filterState: {},
|
||||
onContextMenu: jest.fn(),
|
||||
setDataMask: jest.fn(),
|
||||
emitCrossFilters: false,
|
||||
};
|
||||
|
||||
const lastDeckGLContainerProps = () =>
|
||||
mockDeckGLContainerProps
|
||||
.slice()
|
||||
.reverse()
|
||||
.find(props => props?.viewport !== undefined);
|
||||
|
||||
test('DeckGLGeoJson passes selected MapLibre renderer props to the container', () => {
|
||||
mockDeckGLContainerProps.length = 0;
|
||||
|
||||
renderWithTheme(
|
||||
<DeckGLGeoJson
|
||||
{...geoJsonProps}
|
||||
formData={{
|
||||
...geoJsonProps.formData,
|
||||
map_renderer: 'maplibre',
|
||||
maplibre_style: 'https://example.com/maplibre-style.json',
|
||||
mapbox_style: 'mapbox://styles/mapbox/dark-v9',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastDeckGLContainerProps()).toEqual(
|
||||
expect.objectContaining({
|
||||
mapProvider: 'maplibre',
|
||||
mapStyle: 'https://example.com/maplibre-style.json',
|
||||
mapboxApiKey: 'bootstrap-mapbox-key',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('DeckGLGeoJson passes selected Mapbox renderer props to the container', () => {
|
||||
mockDeckGLContainerProps.length = 0;
|
||||
|
||||
renderWithTheme(
|
||||
<DeckGLGeoJson
|
||||
{...geoJsonProps}
|
||||
formData={{
|
||||
...geoJsonProps.formData,
|
||||
map_renderer: 'mapbox',
|
||||
maplibre_style: 'https://example.com/maplibre-style.json',
|
||||
mapbox_style: 'mapbox://styles/mapbox/satellite-v9',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastDeckGLContainerProps()).toEqual(
|
||||
expect.objectContaining({
|
||||
mapProvider: 'mapbox',
|
||||
mapStyle: 'mapbox://styles/mapbox/satellite-v9',
|
||||
mapboxApiKey: 'bootstrap-mapbox-key',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('DeckGLGeoJson falls back to legacy map_style when provider-specific style is absent', () => {
|
||||
mockDeckGLContainerProps.length = 0;
|
||||
|
||||
renderWithTheme(
|
||||
<DeckGLGeoJson
|
||||
{...geoJsonProps}
|
||||
formData={{
|
||||
...geoJsonProps.formData,
|
||||
map_renderer: 'maplibre',
|
||||
maplibre_style: undefined,
|
||||
map_style: 'legacy-map-style',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastDeckGLContainerProps()).toEqual(
|
||||
expect.objectContaining({
|
||||
mapProvider: 'maplibre',
|
||||
mapStyle: 'legacy-map-style',
|
||||
mapboxApiKey: 'bootstrap-mapbox-key',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
QueryFormData,
|
||||
SetDataMaskHook,
|
||||
SqlaFormData,
|
||||
getMapProviderMapStyle,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
import {
|
||||
@@ -46,6 +47,7 @@ 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;
|
||||
@@ -357,9 +359,19 @@ export type DeckGLGeoJsonProps = {
|
||||
emitCrossFilters?: boolean;
|
||||
};
|
||||
|
||||
export function getPoints(data: Point[]) {
|
||||
export function getPoints(data?: Point[]) {
|
||||
if (!Array.isArray(data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.reduce((acc: Array<any>, feature: any) => {
|
||||
const bounds = geojsonExtent(feature);
|
||||
let bounds;
|
||||
try {
|
||||
bounds = geojsonExtent(feature);
|
||||
} catch {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (bounds) {
|
||||
return [...acc, [bounds[0], bounds[1]], [bounds[2], bounds[3]]];
|
||||
}
|
||||
@@ -382,13 +394,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: getPoints(payload.data.features) || [],
|
||||
points,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -412,12 +424,21 @@ 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]}
|
||||
mapStyle={formData.map_style}
|
||||
mapProvider={selectedMap.mapProvider}
|
||||
mapStyle={selectedMap.mapStyle}
|
||||
mapboxApiKey={getMapboxApiKey()}
|
||||
setControlValue={setControlValue}
|
||||
height={height}
|
||||
width={width}
|
||||
|
||||
@@ -82,6 +82,7 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
{
|
||||
label: t('Map'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[mapProvider],
|
||||
[mapboxStyle],
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* 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
|
||||
@@ -33,10 +34,23 @@ const mockGetColorBreakpointsBuckets = jest.spyOn(
|
||||
);
|
||||
|
||||
// Mock DeckGL container and Legend
|
||||
const mockDeckGLContainerProps: Array<Record<string, unknown>> = [];
|
||||
|
||||
jest.mock('../../DeckGLContainer', () => ({
|
||||
DeckGLContainerStyledWrapper: ({ children }: any) => (
|
||||
<div data-testid="deckgl-container">{children}</div>
|
||||
),
|
||||
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('../../components/Legend', () => ({ categories, position }: any) => (
|
||||
@@ -109,6 +123,95 @@ 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();
|
||||
@@ -119,7 +222,7 @@ describe('DeckGLPolygon bucket generation logic', () => {
|
||||
mockGetColorBreakpointsBuckets.mockReturnValue({});
|
||||
});
|
||||
|
||||
const renderWithTheme = (component: React.ReactElement) =>
|
||||
const renderWithTheme = (component: ReactElement) =>
|
||||
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
|
||||
|
||||
test('should use getBuckets for linear_palette color scheme', () => {
|
||||
@@ -227,7 +330,7 @@ describe('DeckGLPolygon Error Handling and Edge Cases', () => {
|
||||
mockGetColorBreakpointsBuckets.mockReturnValue({});
|
||||
});
|
||||
|
||||
const renderWithTheme = (component: React.ReactElement) =>
|
||||
const renderWithTheme = (component: ReactElement) =>
|
||||
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
|
||||
|
||||
test('handles empty features data gracefully', () => {
|
||||
@@ -291,7 +394,7 @@ describe('DeckGLPolygon Legend Integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const renderWithTheme = (component: React.ReactElement) =>
|
||||
const renderWithTheme = (component: ReactElement) =>
|
||||
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
|
||||
|
||||
test('renders legend with non-empty categories when metric and linear_palette are defined', () => {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
JsonValue,
|
||||
QueryFormData,
|
||||
SetDataMaskHook,
|
||||
getMapProviderMapStyle,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
import { PolygonLayer } from '@deck.gl/layers';
|
||||
@@ -57,6 +58,7 @@ 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,
|
||||
@@ -339,6 +341,12 @@ 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' }}>
|
||||
@@ -347,7 +355,9 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
|
||||
viewport={viewport}
|
||||
layers={getLayers()}
|
||||
setControlValue={setControlValue}
|
||||
mapStyle={formData.map_style}
|
||||
mapProvider={selectedMap.mapProvider}
|
||||
mapStyle={selectedMap.mapStyle}
|
||||
mapboxApiKey={getMapboxApiKey()}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { ControlPanelState } from '@superset-ui/chart-controls';
|
||||
import { OSM_TILE_STYLE_URL } from '@superset-ui/core/utils/mapStyles';
|
||||
import { mapProvider, maplibreStyle } from './Shared_DeckGL';
|
||||
|
||||
const setBootstrap = ({
|
||||
conf = {},
|
||||
deckglTiles,
|
||||
}: {
|
||||
conf?: Record<string, unknown>;
|
||||
deckglTiles?: unknown;
|
||||
}) => {
|
||||
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
|
||||
common: {
|
||||
conf,
|
||||
...(deckglTiles === undefined ? {} : { deckgl_tiles: deckglTiles }),
|
||||
},
|
||||
})}'></div>`;
|
||||
};
|
||||
|
||||
type MapProviderControlConfig = typeof mapProvider.config & {
|
||||
mapStateToProps: (state: ControlPanelState) => {
|
||||
options?: unknown;
|
||||
warning?: string;
|
||||
default?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const getMapProviderProps = (value?: string) =>
|
||||
(mapProvider.config as MapProviderControlConfig).mapStateToProps({
|
||||
form_data: { map_renderer: value },
|
||||
} as unknown as ControlPanelState);
|
||||
|
||||
type MapLibreStyleControlConfig = typeof maplibreStyle.config & {
|
||||
mapStateToProps: () => {
|
||||
choices: unknown;
|
||||
default: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const getMapLibreStyleProps = () =>
|
||||
(maplibreStyle.config as MapLibreStyleControlConfig).mapStateToProps();
|
||||
|
||||
test('deck.gl MapLibre style choices expose Streets (OSM)', () => {
|
||||
expect(maplibreStyle.config.choices).toContainEqual([
|
||||
OSM_TILE_STYLE_URL,
|
||||
'Streets (OSM)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('deck.gl map renderer hides Mapbox when no key exists for new selections', () => {
|
||||
setBootstrap({ conf: {} });
|
||||
|
||||
const props = getMapProviderProps('maplibre');
|
||||
|
||||
expect(props.options).toEqual([
|
||||
{ value: 'maplibre', label: 'MapLibre (open-source)' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('deck.gl map renderer keeps saved Mapbox visible while disabled without a key', () => {
|
||||
setBootstrap({ conf: {} });
|
||||
|
||||
const props = getMapProviderProps('mapbox');
|
||||
|
||||
expect(props.options).toContainEqual({
|
||||
value: 'mapbox',
|
||||
label: 'Mapbox (API key required)',
|
||||
disabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('deck.gl map renderer enables Mapbox when a key exists', () => {
|
||||
setBootstrap({ conf: { MAPBOX_API_KEY: 'pk.test' } });
|
||||
|
||||
const props = getMapProviderProps('maplibre');
|
||||
|
||||
expect(props.options).toEqual([
|
||||
{ value: 'maplibre', label: 'MapLibre (open-source)' },
|
||||
{ value: 'mapbox', label: 'Mapbox (API key required)' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('deck.gl map renderer keeps the original explanatory description', () => {
|
||||
expect(mapProvider.config.description).toBe(
|
||||
'Select the map tile provider. MapLibre is open-source and requires no API key. Mapbox requires MAPBOX_API_KEY to be configured in Superset.',
|
||||
);
|
||||
});
|
||||
|
||||
test('deck.gl map renderer defaults to configured Mapbox when a key exists', () => {
|
||||
setBootstrap({
|
||||
conf: { DEFAULT_MAP_RENDERER: 'mapbox', MAPBOX_API_KEY: 'pk.test' },
|
||||
});
|
||||
|
||||
expect(getMapProviderProps('maplibre').default).toBe('mapbox');
|
||||
});
|
||||
|
||||
test('deck.gl map renderer falls back from configured Mapbox default without a key', () => {
|
||||
setBootstrap({ conf: { DEFAULT_MAP_RENDERER: 'mapbox' } });
|
||||
|
||||
expect(getMapProviderProps('maplibre').default).toBe('maplibre');
|
||||
});
|
||||
|
||||
test('deck.gl map style falls back to default tiles for empty overrides', () => {
|
||||
setBootstrap({ deckglTiles: [] });
|
||||
|
||||
const props = getMapLibreStyleProps();
|
||||
|
||||
expect(props.choices).toContainEqual([OSM_TILE_STYLE_URL, 'Streets (OSM)']);
|
||||
expect(props.default).toBe(
|
||||
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||
);
|
||||
});
|
||||
|
||||
test('deck.gl map style falls back to default tiles for malformed overrides', () => {
|
||||
setBootstrap({
|
||||
deckglTiles: [
|
||||
['https://tiles.example.com/{z}/{x}/{y}.png'],
|
||||
['https://tiles.example.com/{z}/{x}/{y}.png', 'Custom', 'Extra'],
|
||||
['', 'Empty URL'],
|
||||
],
|
||||
});
|
||||
|
||||
const props = getMapLibreStyleProps();
|
||||
|
||||
expect(props.choices).toContainEqual([OSM_TILE_STYLE_URL, 'Streets (OSM)']);
|
||||
expect(props.default).toBe(
|
||||
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||
);
|
||||
});
|
||||
|
||||
test('deck.gl map style accepts well-formed tile overrides', () => {
|
||||
setBootstrap({
|
||||
deckglTiles: [['https://tiles.example.com/style.json', 'Custom']],
|
||||
});
|
||||
|
||||
const props = getMapLibreStyleProps();
|
||||
|
||||
expect(props.choices).toEqual([
|
||||
['https://tiles.example.com/style.json', 'Custom'],
|
||||
]);
|
||||
expect(props.default).toBe('https://tiles.example.com/style.json');
|
||||
});
|
||||
@@ -25,9 +25,20 @@ 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,
|
||||
@@ -40,15 +51,23 @@ 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 };
|
||||
|
||||
let deckglTiles: string[][];
|
||||
type DeckGLTileChoice = [string, string];
|
||||
type MapStyleVisibilityProps = {
|
||||
controls?: ControlStateMapping;
|
||||
};
|
||||
type MetricControlValue = {
|
||||
type?: unknown;
|
||||
value?: unknown;
|
||||
};
|
||||
|
||||
export const DEFAULT_DECKGL_TILES = [
|
||||
export const DEFAULT_DECKGL_TILES: DeckGLTileChoice[] = [
|
||||
[
|
||||
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||
'Light (Carto)',
|
||||
@@ -62,9 +81,10 @@ export const DEFAULT_DECKGL_TILES = [
|
||||
'Streets (Carto)',
|
||||
],
|
||||
['https://tiles.openfreemap.org/styles/liberty', 'Liberty (OpenFreeMap)'],
|
||||
[OSM_TILE_STYLE_URL, 'Streets (OSM)'],
|
||||
];
|
||||
|
||||
export const DEFAULT_MAPBOX_TILES = [
|
||||
export const DEFAULT_MAPBOX_TILES: DeckGLTileChoice[] = [
|
||||
['mapbox://styles/mapbox/streets-v9', 'Streets (Mapbox)'],
|
||||
['mapbox://styles/mapbox/dark-v9', 'Dark (Mapbox)'],
|
||||
['mapbox://styles/mapbox/light-v9', 'Light (Mapbox)'],
|
||||
@@ -73,17 +93,56 @@ export const DEFAULT_MAPBOX_TILES = [
|
||||
['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 = () => {
|
||||
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 bootstrapData = getBootstrapDataFromDocument();
|
||||
const deckglTilesOverride = (
|
||||
bootstrapData as {
|
||||
common?: { deckgl_tiles?: unknown };
|
||||
} | null
|
||||
)?.common?.deckgl_tiles;
|
||||
return isDeckGLTileChoices(deckglTilesOverride)
|
||||
? deckglTilesOverride
|
||||
: DEFAULT_DECKGL_TILES;
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -456,15 +515,26 @@ export const mapProvider = {
|
||||
label: t('Map Renderer'),
|
||||
clearable: false,
|
||||
renderTrigger: true,
|
||||
choices: [
|
||||
['maplibre', t('MapLibre (open-source)')],
|
||||
['mapbox', t('Mapbox (API key required)')],
|
||||
],
|
||||
options: getLabeledMapRendererOptions({
|
||||
hasMapboxKey: hasMapboxApiKey(),
|
||||
}),
|
||||
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(),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -476,13 +546,14 @@ export const maplibreStyle = {
|
||||
clearable: false,
|
||||
renderTrigger: true,
|
||||
freeForm: true,
|
||||
choices: getDeckGLTiles(),
|
||||
default: getDeckGLTiles()[0][0],
|
||||
choices: DEFAULT_DECKGL_TILES,
|
||||
default: DEFAULT_DECKGL_TILES[0][0],
|
||||
description: t(
|
||||
'Base layer map style. Accepts a MapLibre-compatible style URL.',
|
||||
),
|
||||
visibility: ({ controls }: ControlPanelState) =>
|
||||
visibility: ({ controls }: MapStyleVisibilityProps) =>
|
||||
controls?.map_renderer?.value !== 'mapbox',
|
||||
mapStateToProps: getMapLibreStyleProps,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -499,7 +570,7 @@ export const mapboxStyle = {
|
||||
description: t(
|
||||
'Base layer map style. Accepts a Mapbox style URL (mapbox://styles/...).',
|
||||
),
|
||||
visibility: ({ controls }: ControlPanelState) =>
|
||||
visibility: ({ controls }: MapStyleVisibilityProps) =>
|
||||
controls?.map_renderer?.value === 'mapbox',
|
||||
},
|
||||
};
|
||||
@@ -517,14 +588,14 @@ export const geojsonColumn = {
|
||||
},
|
||||
};
|
||||
|
||||
const extractMetricsFromFormData = (formData: any) => {
|
||||
const metrics = new Set<string>();
|
||||
const extractMetricsFromFormData = (formData: QueryFormData) => {
|
||||
const metrics = new Set<unknown>();
|
||||
|
||||
if (formData.metrics) {
|
||||
(Array.isArray(formData.metrics)
|
||||
? formData.metrics
|
||||
: [formData.metrics]
|
||||
).forEach((metric: any) => metrics.add(metric));
|
||||
).forEach((metric: unknown) => metrics.add(metric));
|
||||
}
|
||||
|
||||
if (formData.point_radius_fixed?.value) {
|
||||
@@ -533,8 +604,9 @@ const extractMetricsFromFormData = (formData: any) => {
|
||||
|
||||
Object.entries(formData).forEach(([, value]) => {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
if ((value as any).type === 'metric' && (value as any).value) {
|
||||
metrics.add((value as any).value);
|
||||
const controlValue = value as MetricControlValue;
|
||||
if (controlValue.type === 'metric' && controlValue.value) {
|
||||
metrics.add(controlValue.value);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -555,7 +627,7 @@ export const tooltipContents = {
|
||||
),
|
||||
ghostButtonText: t('Drop columns/metrics here or click'),
|
||||
disabledTabs: new Set(['saved', 'sqlExpression']),
|
||||
mapStateToProps: (state: any) => {
|
||||
mapStateToProps: (state: ControlPanelState) => {
|
||||
const { datasource, form_data: formData } = state;
|
||||
|
||||
const selectedMetrics = formData
|
||||
@@ -564,7 +636,8 @@ export const tooltipContents = {
|
||||
|
||||
return {
|
||||
columns: datasource?.columns || [],
|
||||
savedMetrics: datasource?.metrics || [],
|
||||
savedMetrics:
|
||||
datasource && 'metrics' in datasource ? datasource.metrics || [] : [],
|
||||
datasource,
|
||||
selectedMetrics,
|
||||
disabledTabs: new Set(['saved', 'sqlExpression']),
|
||||
@@ -584,7 +657,7 @@ export const tooltipTemplate = {
|
||||
default: '',
|
||||
description: '',
|
||||
placeholder: '',
|
||||
mapStateToProps: (_state: any, control: any) => ({
|
||||
mapStateToProps: (_state: ControlPanelState, control: ControlState) => ({
|
||||
value: control.value,
|
||||
}),
|
||||
},
|
||||
@@ -702,8 +775,13 @@ export const deckGLBreakpointMetric: CustomControlItem = {
|
||||
// mapStateToProps: (state: ControlPanelState) => ({
|
||||
// datasource: state.datasource,
|
||||
// }),
|
||||
visibility: ({ controls }: { controls: any }) =>
|
||||
isColorSchemeTypeVisible(controls, COLOR_SCHEME_TYPES.color_breakpoints),
|
||||
visibility: ({ controls }: MapStyleVisibilityProps) =>
|
||||
controls
|
||||
? isColorSchemeTypeVisible(
|
||||
controls,
|
||||
COLOR_SCHEME_TYPES.color_breakpoints,
|
||||
)
|
||||
: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -17,11 +17,22 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
// Superset passes app-level objects through navigation state (e.g.
|
||||
// SQL Lab's `requestedQuery`, explore's `saveAction`). TanStack types
|
||||
// history state as a closed interface; open it up for arbitrary keys.
|
||||
declare module '@tanstack/history' {
|
||||
interface HistoryState {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
}
|
||||
import { getMapboxApiKey, hasMapboxApiKey } from './mapbox';
|
||||
|
||||
const setBootstrap = (conf: Record<string, unknown>) => {
|
||||
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
|
||||
common: { conf },
|
||||
})}'></div>`;
|
||||
};
|
||||
|
||||
test('deck.gl Mapbox helpers read key presence from bootstrap data', () => {
|
||||
setBootstrap({ MAPBOX_API_KEY: 'pk.test' });
|
||||
|
||||
expect(getMapboxApiKey()).toBe('pk.test');
|
||||
expect(hasMapboxApiKey()).toBe(true);
|
||||
|
||||
setBootstrap({});
|
||||
|
||||
expect(getMapboxApiKey()).toBe('');
|
||||
expect(hasMapboxApiKey()).toBe(false);
|
||||
});
|
||||
@@ -17,19 +17,15 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
getMapboxApiKeyFromBootstrap,
|
||||
hasMapboxApiKey as hasBootstrapMapboxApiKey,
|
||||
} from '@superset-ui/core/utils/mapStyles';
|
||||
|
||||
export function getMapboxApiKey(): string {
|
||||
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 '';
|
||||
return getMapboxApiKeyFromBootstrap();
|
||||
}
|
||||
|
||||
export function hasMapboxApiKey(): boolean {
|
||||
return hasBootstrapMapboxApiKey();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import {
|
||||
DataMaskStateWithId,
|
||||
ExtraFormData,
|
||||
Filter,
|
||||
NativeFiltersState,
|
||||
NativeFilterType,
|
||||
} from '@superset-ui/core';
|
||||
@@ -458,6 +459,25 @@ export const mockQueryDataForCountries = [
|
||||
{ country_name: 'Zimbabwe', 'SUM(SP_POP_TOTL)': 509866860 },
|
||||
];
|
||||
|
||||
export const createSelectNativeFilter = (
|
||||
id: string,
|
||||
name: string,
|
||||
column: string = name,
|
||||
): Filter => ({
|
||||
id,
|
||||
name,
|
||||
type: NativeFilterType.NativeFilter,
|
||||
filterType: 'filter_select',
|
||||
targets: [{ datasetId: 2, column: { name: column } }],
|
||||
defaultDataMask: { filterState: { value: null }, extraFormData: {} },
|
||||
controlValues: {},
|
||||
cascadeParentIds: [],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
description: '',
|
||||
chartsInScope: [],
|
||||
tabsInScope: [],
|
||||
});
|
||||
|
||||
export const buildNativeFilter = (
|
||||
id: string,
|
||||
name: string,
|
||||
|
||||
@@ -19,18 +19,18 @@
|
||||
|
||||
import { ThemeProvider } from '@apache-superset/core/theme';
|
||||
import querystring from 'query-string';
|
||||
import { StandaloneRouter } from 'src/router/StandaloneRouter';
|
||||
import { TanstackRouterAdapter } from 'src/router/queryParamAdapter';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
|
||||
|
||||
export function ProviderWrapper(props: any) {
|
||||
const { children, theme } = props;
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<StandaloneRouter>
|
||||
<Router>
|
||||
<QueryParamProvider
|
||||
adapter={TanstackRouterAdapter}
|
||||
adapter={ReactRouter5Adapter}
|
||||
options={{
|
||||
searchStringToObject: querystring.parse,
|
||||
objectToSearchString: (object: Record<string, any>) =>
|
||||
@@ -39,7 +39,7 @@ export function ProviderWrapper(props: any) {
|
||||
>
|
||||
{children}
|
||||
</QueryParamProvider>
|
||||
</StandaloneRouter>
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,14 +33,14 @@ import {
|
||||
} from '@apache-superset/core/theme';
|
||||
import { SupersetThemeProvider } from 'src/theme/ThemeProvider';
|
||||
import { ThemeController } from 'src/theme/ThemeController';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { DndContext } from '@dnd-kit/core';
|
||||
import reducerIndex from 'spec/helpers/reducerIndex';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import { StandaloneRouter } from 'src/router/StandaloneRouter';
|
||||
import { TanstackRouterAdapter } from 'src/router/queryParamAdapter';
|
||||
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
|
||||
import { configureStore, Store } from '@reduxjs/toolkit';
|
||||
import { api } from 'src/hooks/apiResources/queryApi';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
@@ -55,8 +55,6 @@ type Options = Omit<RenderOptions, 'queries'> & {
|
||||
initialState?: {};
|
||||
reducers?: {};
|
||||
store?: Store;
|
||||
/** Starting history entries for the test router (memory history). */
|
||||
initialEntries?: string[];
|
||||
};
|
||||
|
||||
const themeController = new ThemeController({ themeObject });
|
||||
@@ -86,7 +84,6 @@ export function createWrapper(options?: Options) {
|
||||
initialState,
|
||||
reducers,
|
||||
store,
|
||||
initialEntries,
|
||||
} = options || {};
|
||||
|
||||
return ({ children }: { children?: ReactNode }) => {
|
||||
@@ -119,18 +116,14 @@ export function createWrapper(options?: Options) {
|
||||
|
||||
if (useQueryParams) {
|
||||
result = (
|
||||
<QueryParamProvider adapter={TanstackRouterAdapter}>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
{result}
|
||||
</QueryParamProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (useRouter || useQueryParams || initialEntries) {
|
||||
result = (
|
||||
<StandaloneRouter initialEntries={initialEntries}>
|
||||
{result}
|
||||
</StandaloneRouter>
|
||||
);
|
||||
if (useRouter) {
|
||||
result = <BrowserRouter>{result}</BrowserRouter>;
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Navigate } from '@tanstack/react-router';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
@@ -208,7 +208,13 @@ class App extends PureComponent<AppProps, AppState> {
|
||||
render() {
|
||||
const { queries, queriesLastUpdate } = this.props;
|
||||
if (this.state.hash && this.state.hash === '#search') {
|
||||
return <Navigate to="/sqllab/history/" replace />;
|
||||
return (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: '/sqllab/history/',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SqlLabStyles data-test="SqlLabApp" className="App SqlLab">
|
||||
|
||||
@@ -22,7 +22,7 @@ import type { SqlLabRootState } from 'src/SqlLab/types';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { useComponentDidUpdate } from '@superset-ui/core';
|
||||
import { Grid } from '@superset-ui/core/components';
|
||||
import { views } from 'src/core';
|
||||
import { useViews } from 'src/core';
|
||||
import { Splitter } from 'src/components/Splitter';
|
||||
import useEffectEvent from 'src/hooks/useEffectEvent';
|
||||
import useStoredSidebarWidth from 'src/components/ResizableSidebar/useStoredSidebarWidth';
|
||||
@@ -96,7 +96,7 @@ const AppLayout: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
|
||||
setRightWidth(possibleRightWidth);
|
||||
}
|
||||
};
|
||||
const viewItems = views.getViews(ViewLocations.sqllab.rightSidebar) || [];
|
||||
const viewItems = useViews(ViewLocations.sqllab.rightSidebar) || [];
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { StandaloneRouter } from 'src/router/StandaloneRouter';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { initialState } from 'src/SqlLab/fixtures';
|
||||
@@ -32,11 +32,11 @@ const setup = (
|
||||
overridesInitialState?: RootState,
|
||||
) =>
|
||||
render(
|
||||
<StandaloneRouter initialEntries={[url]}>
|
||||
<MemoryRouter initialEntries={[url]}>
|
||||
<LocationProvider>
|
||||
<PopEditorTab />
|
||||
</LocationProvider>
|
||||
</StandaloneRouter>,
|
||||
</MemoryRouter>,
|
||||
{
|
||||
useRedux: true,
|
||||
initialState: overridesInitialState || initialState,
|
||||
|
||||
@@ -30,8 +30,7 @@ import {
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
import { pushAppHref } from 'src/router/navigation';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { pick } from 'lodash';
|
||||
import {
|
||||
Button,
|
||||
@@ -233,7 +232,7 @@ const ResultSet = ({
|
||||
canExportDataSqlLab: canExportData,
|
||||
canCopyClipboardSqlLab: canCopyClipboard,
|
||||
} = usePermissions();
|
||||
const router = useRouter();
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
const logAction = useLogAction({ queryId, sqlEditorId: query.sqlEditorId });
|
||||
const { showConfirm, ConfirmModal } = useConfirmModal();
|
||||
@@ -315,7 +314,7 @@ const ResultSet = ({
|
||||
if (openInNewWindow) {
|
||||
window.open(url, '_blank', 'noreferrer');
|
||||
} else {
|
||||
pushAppHref(router, url);
|
||||
history.push(url);
|
||||
}
|
||||
} else {
|
||||
addDangerToast(t('Unable to create chart without a query id.'));
|
||||
|
||||
@@ -31,7 +31,7 @@ import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { SqlLabRootState } from 'src/SqlLab/types';
|
||||
import { ViewLocations } from 'src/SqlLab/contributions';
|
||||
import PanelToolbar from 'src/components/PanelToolbar';
|
||||
import { views } from 'src/core';
|
||||
import { useViews } from 'src/core';
|
||||
import { resolveView } from 'src/core/views';
|
||||
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
|
||||
import useLogAction from 'src/logger/useLogAction';
|
||||
@@ -107,7 +107,7 @@ const SouthPane = ({
|
||||
const editorId = tabViewId ?? id;
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const viewItems = views.getViews(ViewLocations.sqllab.panels) || [];
|
||||
const viewItems = useViews(ViewLocations.sqllab.panels) || [];
|
||||
const { offline, tables } = useSelector(
|
||||
({ sqlLab: { offline, tables } }: SqlLabRootState) => ({
|
||||
offline,
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Flex } from '@superset-ui/core/components';
|
||||
import ViewListExtension from 'src/components/ViewListExtension';
|
||||
import { views } from 'src/core';
|
||||
import { useViews } from 'src/core';
|
||||
import { SQL_EDITOR_STATUSBAR_HEIGHT } from 'src/SqlLab/constants';
|
||||
import { ViewLocations } from 'src/SqlLab/contributions';
|
||||
|
||||
@@ -38,7 +38,7 @@ const Container = styled(Flex)`
|
||||
`;
|
||||
|
||||
const StatusBar = () => {
|
||||
const statusBarViews = views.getViews(ViewLocations.sqllab.statusBar) || [];
|
||||
const statusBarViews = useViews(ViewLocations.sqllab.statusBar) || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
@@ -78,7 +78,7 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const { addDangerToast } = useToasts();
|
||||
const theme = useTheme();
|
||||
const [formDataKey, setFormDataKey] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const dashboardPageId = useContext(DashboardPageIdContext);
|
||||
const onEditChartClick = useCallback(() => {
|
||||
dispatch(
|
||||
@@ -97,7 +97,9 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
|
||||
if (isEmbedded()) return;
|
||||
postFormData(Number(datasource_id), datasource_type, formData, 0)
|
||||
.then(key => {
|
||||
setFormDataKey(key);
|
||||
setUrl(
|
||||
`/explore/?form_data_key=${key}&dashboard_page_id=${dashboardPageId}`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
addDangerToast(t('Failed to generate chart edit URL'));
|
||||
@@ -109,7 +111,7 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
|
||||
datasource_type,
|
||||
formData,
|
||||
]);
|
||||
const isEditDisabled = !formDataKey || !canExplore;
|
||||
const isEditDisabled = !url || !canExplore;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -131,11 +133,7 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
|
||||
text-decoration: none;
|
||||
}
|
||||
`}
|
||||
to="/explore/"
|
||||
search={{
|
||||
form_data_key: formDataKey,
|
||||
dashboard_page_id: dashboardPageId,
|
||||
}}
|
||||
to={url}
|
||||
>
|
||||
{t('Edit chart')}
|
||||
</Link>
|
||||
|
||||
@@ -25,12 +25,10 @@ import DrillDetailModal from './DrillDetailModal';
|
||||
|
||||
jest.mock('./DrillDetailPane', () => () => null);
|
||||
const mockHistoryPush = jest.fn();
|
||||
jest.mock('@tanstack/react-router', () => ({
|
||||
...jest.requireActual('@tanstack/react-router'),
|
||||
useRouter: () => ({
|
||||
history: {
|
||||
push: mockHistoryPush,
|
||||
},
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useHistory: () => ({
|
||||
push: mockHistoryPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
import { pushAppHref } from 'src/router/navigation';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
BinaryQueryObjectFilterClause,
|
||||
@@ -99,7 +98,7 @@ export default function DrillDetailModal({
|
||||
dataset,
|
||||
}: DrillDetailModalProps) {
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
const history = useHistory();
|
||||
const dashboardPageId = useContext(DashboardPageIdContext);
|
||||
const { slice_name: chartName } = useSelector(
|
||||
(state: { sliceEntities: { slices: Record<number, Slice> } }) =>
|
||||
@@ -115,8 +114,8 @@ export default function DrillDetailModal({
|
||||
);
|
||||
|
||||
const exploreChart = useCallback(() => {
|
||||
pushAppHref(router, exploreUrl);
|
||||
}, [exploreUrl, router]);
|
||||
history.push(exploreUrl);
|
||||
}, [exploreUrl, history]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
||||
@@ -17,49 +17,32 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { sanitizeUrl } from '@braintree/sanitize-url';
|
||||
import { AnchorHTMLAttributes } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { parseSearch } from 'src/router/searchParams';
|
||||
import { PropsWithoutRef, RefAttributes } from 'react';
|
||||
import { Link, LinkProps } from 'react-router-dom';
|
||||
import { isUrlExternal, parseUrl } from 'src/utils/urlUtils';
|
||||
|
||||
export type GenericLinkProps = Omit<
|
||||
AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
'href'
|
||||
> & {
|
||||
to: string;
|
||||
replace?: boolean;
|
||||
};
|
||||
|
||||
export const GenericLink = ({
|
||||
to: rawTo,
|
||||
export const GenericLink = <S,>({
|
||||
to,
|
||||
component,
|
||||
replace,
|
||||
innerRef,
|
||||
children,
|
||||
...rest
|
||||
}: GenericLinkProps) => {
|
||||
// Callers may pass undefined at runtime (e.g. backend rows without a URL).
|
||||
const to = typeof rawTo === 'string' ? rawTo : '';
|
||||
if (to && isUrlExternal(to)) {
|
||||
}: PropsWithoutRef<LinkProps<S>> & RefAttributes<HTMLAnchorElement>) => {
|
||||
if (typeof to === 'string' && isUrlExternal(to)) {
|
||||
return (
|
||||
<a data-test="external-link" href={sanitizeUrl(parseUrl(to))} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
const hashIndex = to.indexOf('#');
|
||||
const hash = hashIndex === -1 ? undefined : to.slice(hashIndex + 1);
|
||||
const withoutHash = hashIndex === -1 ? to : to.slice(0, hashIndex);
|
||||
const searchIndex = withoutHash.indexOf('?');
|
||||
const pathname =
|
||||
searchIndex === -1 ? withoutHash : withoutHash.slice(0, searchIndex);
|
||||
const searchStr =
|
||||
searchIndex === -1 ? '' : withoutHash.slice(searchIndex + 1);
|
||||
return (
|
||||
<Link
|
||||
data-test="internal-link"
|
||||
to={pathname}
|
||||
search={searchStr ? parseSearch(searchStr) : undefined}
|
||||
hash={hash}
|
||||
to={to}
|
||||
component={component}
|
||||
replace={replace}
|
||||
innerRef={innerRef}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTruncation } from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import CrossLinksTooltip from './CrossLinksTooltip';
|
||||
|
||||
export type CrossLinkProps = {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Tooltip } from '@superset-ui/core/components';
|
||||
|
||||
export type CrossLinksTooltipProps = {
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
import { render, screen, within, waitFor } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import { StandaloneRouter } from 'src/router/StandaloneRouter';
|
||||
import { TanstackRouterAdapter } from 'src/router/queryParamAdapter';
|
||||
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import thunk from 'redux-thunk';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import { ReactNode } from 'react';
|
||||
@@ -206,11 +206,11 @@ test('redirects to first page when page index is invalid', async () => {
|
||||
const factory = (overrides?: Partial<ListViewProps>) => {
|
||||
const props = { ...mockedPropsComprehensive, ...overrides };
|
||||
return render(
|
||||
<StandaloneRouter initialEntries={['/']}>
|
||||
<QueryParamProvider adapter={TanstackRouterAdapter}>
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView {...props} />
|
||||
</QueryParamProvider>
|
||||
</StandaloneRouter>,
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,11 +17,12 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { useMenu } from 'src/core';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Button, Divider, Dropdown } from '@superset-ui/core/components';
|
||||
import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { commands, menus } from 'src/core';
|
||||
import { commands } from 'src/core';
|
||||
|
||||
export interface PanelToolbarProps {
|
||||
viewId: string;
|
||||
@@ -35,7 +36,7 @@ const PanelToolbar = ({
|
||||
defaultSecondaryActions,
|
||||
}: PanelToolbarProps) => {
|
||||
const theme = useTheme();
|
||||
const menu = menus.getMenu(viewId);
|
||||
const menu = useMenu(viewId);
|
||||
|
||||
const primaryItems = menu?.primary || [];
|
||||
const secondaryItems = menu?.secondary || [];
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { TagType } from 'src/types/TagType';
|
||||
import { Tag as AntdTag } from '@superset-ui/core/components/Tag';
|
||||
import { Tooltip } from '@superset-ui/core/components/Tooltip';
|
||||
@@ -82,8 +82,7 @@ const SupersetTag = ({
|
||||
{' '}
|
||||
{id ? (
|
||||
<Link
|
||||
to="/superset/all_entities/"
|
||||
search={{ id: String(id) }}
|
||||
to={`/superset/all_entities/?id=${id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -39,8 +39,7 @@ jest.mock('./EditorProviders', () => ({
|
||||
getInstance: () => ({
|
||||
getProvider: jest.fn().mockReturnValue(undefined),
|
||||
hasProvider: jest.fn().mockReturnValue(false),
|
||||
onDidRegister: jest.fn().mockReturnValue({ dispose: jest.fn() }),
|
||||
onDidUnregister: jest.fn().mockReturnValue({ dispose: jest.fn() }),
|
||||
subscribe: jest.fn().mockReturnValue(() => {}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -26,13 +26,12 @@
|
||||
* back to the default Ace editor.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, forwardRef } from 'react';
|
||||
import { useSyncExternalStore, forwardRef } from 'react';
|
||||
import type { editors } from '@apache-superset/core';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import EditorProviders from './EditorProviders';
|
||||
import AceEditorProvider from './AceEditorProvider';
|
||||
|
||||
type EditorLanguage = editors.EditorLanguage;
|
||||
type EditorProps = editors.EditorProps;
|
||||
type EditorHandle = editors.EditorHandle;
|
||||
|
||||
@@ -42,49 +41,6 @@ type EditorHandle = editors.EditorHandle;
|
||||
*/
|
||||
export type EditorHostProps = EditorProps;
|
||||
|
||||
/**
|
||||
* Hook to track editor provider changes.
|
||||
* Returns the provider for the specified language and re-renders when it changes.
|
||||
*/
|
||||
const useEditorProvider = (language: EditorLanguage) => {
|
||||
const manager = EditorProviders.getInstance();
|
||||
const [provider, setProvider] = useState(() => manager.getProvider(language));
|
||||
|
||||
useEffect(() => {
|
||||
// Helper to safely update provider state, always fetching latest from manager
|
||||
const updateProvider = () => {
|
||||
setProvider(prev => {
|
||||
const current = manager.getProvider(language);
|
||||
return current !== prev ? current : prev;
|
||||
});
|
||||
};
|
||||
|
||||
// Subscribe to provider changes
|
||||
const registerDisposable = manager.onDidRegister(event => {
|
||||
if (event.editor.languages.includes(language)) {
|
||||
updateProvider();
|
||||
}
|
||||
});
|
||||
|
||||
const unregisterDisposable = manager.onDidUnregister(event => {
|
||||
if (event.editor.languages.includes(language)) {
|
||||
updateProvider();
|
||||
}
|
||||
});
|
||||
|
||||
// Check for provider on mount (in case it was registered before this component mounted)
|
||||
updateProvider();
|
||||
|
||||
return () => {
|
||||
registerDisposable.dispose();
|
||||
unregisterDisposable.dispose();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [language, manager]);
|
||||
|
||||
return provider;
|
||||
};
|
||||
|
||||
/**
|
||||
* EditorHost component that dynamically resolves and renders the appropriate editor.
|
||||
*
|
||||
@@ -106,7 +62,12 @@ const useEditorProvider = (language: EditorLanguage) => {
|
||||
const EditorHost = forwardRef<EditorHandle, EditorHostProps>((props, ref) => {
|
||||
const { language } = props;
|
||||
const theme = useTheme();
|
||||
const provider = useEditorProvider(language);
|
||||
const manager = EditorProviders.getInstance();
|
||||
const provider = useSyncExternalStore(
|
||||
manager.subscribe,
|
||||
() => manager.getProvider(language),
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
// Merge theme into props
|
||||
const propsWithTheme = { ...props, theme };
|
||||
|
||||
@@ -93,6 +93,17 @@ class EditorProviders {
|
||||
*/
|
||||
private unregisterEmitter = new EventEmitter<EditorUnregisteredEvent>();
|
||||
|
||||
private syncListeners: Set<() => void> = new Set();
|
||||
|
||||
/**
|
||||
* Stable-reference subscribe function for useSyncExternalStore.
|
||||
* Defined as an arrow property so the reference is bound to this instance at construction.
|
||||
*/
|
||||
public subscribe = (listener: () => void): (() => void) => {
|
||||
this.syncListeners.add(listener);
|
||||
return () => this.syncListeners.delete(listener);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
private constructor() {
|
||||
// Private constructor for singleton pattern
|
||||
@@ -145,6 +156,7 @@ class EditorProviders {
|
||||
|
||||
// Fire registration event
|
||||
this.registerEmitter.fire({ editor });
|
||||
this.syncListeners.forEach(l => l());
|
||||
|
||||
// Return disposable for cleanup
|
||||
return new Disposable(() => {
|
||||
@@ -176,6 +188,7 @@ class EditorProviders {
|
||||
|
||||
// Fire unregistration event
|
||||
this.unregisterEmitter.fire({ editor });
|
||||
this.syncListeners.forEach(l => l());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,6 +247,7 @@ class EditorProviders {
|
||||
public reset(): void {
|
||||
this.providers.clear();
|
||||
this.languageToProvider.clear();
|
||||
this.syncListeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
* and resolution functions declared in the API types.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { editors as editorsApi } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
import EditorProviders from './EditorProviders';
|
||||
@@ -109,6 +110,23 @@ export const onDidUnregisterEditor = (
|
||||
return manager.onDidUnregister(listener);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that returns the editor provider for a specific language and re-renders when it changes.
|
||||
*
|
||||
* @param language The language to get an editor for
|
||||
* @returns The editor provider or undefined if no extension provides one
|
||||
*/
|
||||
export const useEditor = (
|
||||
language: EditorLanguage,
|
||||
): EditorProvider | undefined => {
|
||||
const manager = EditorProviders.getInstance();
|
||||
return useSyncExternalStore(
|
||||
manager.subscribe,
|
||||
() => manager.getProvider(language),
|
||||
() => undefined,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Editors API object for use in the extension system.
|
||||
*/
|
||||
|
||||
@@ -24,11 +24,14 @@
|
||||
* Extensions register menu items as side effects at import time.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import type { menus as menusApi } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
|
||||
type MenuItem = menusApi.MenuItem;
|
||||
type Menu = menusApi.Menu;
|
||||
type MenuItemRegisteredEvent = menusApi.MenuItemRegisteredEvent;
|
||||
type MenuItemUnregisteredEvent = menusApi.MenuItemUnregisteredEvent;
|
||||
|
||||
type StoredMenuItem = {
|
||||
item: MenuItem;
|
||||
@@ -38,6 +41,27 @@ type StoredMenuItem = {
|
||||
|
||||
const menuItems: StoredMenuItem[] = [];
|
||||
|
||||
const syncListeners = new Set<() => void>();
|
||||
const subscribe = (listener: () => void) => {
|
||||
syncListeners.add(listener);
|
||||
return () => syncListeners.delete(listener);
|
||||
};
|
||||
|
||||
const registerListeners = new Set<(e: MenuItemRegisteredEvent) => void>();
|
||||
const unregisterListeners = new Set<(e: MenuItemUnregisteredEvent) => void>();
|
||||
|
||||
const menuCache = new Map<string, Menu | undefined>();
|
||||
const notifyRegister = (event: MenuItemRegisteredEvent) => {
|
||||
menuCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
registerListeners.forEach(l => l(event));
|
||||
};
|
||||
const notifyUnregister = (event: MenuItemUnregisteredEvent) => {
|
||||
menuCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
unregisterListeners.forEach(l => l(event));
|
||||
};
|
||||
|
||||
const registerMenuItem: typeof menusApi.registerMenuItem = (
|
||||
item: MenuItem,
|
||||
location: string,
|
||||
@@ -45,11 +69,13 @@ const registerMenuItem: typeof menusApi.registerMenuItem = (
|
||||
): Disposable => {
|
||||
const stored: StoredMenuItem = { item, location, group };
|
||||
menuItems.push(stored);
|
||||
notifyRegister({ item, location, group });
|
||||
return new Disposable(() => {
|
||||
const index = menuItems.indexOf(stored);
|
||||
if (index >= 0) {
|
||||
menuItems.splice(index, 1);
|
||||
}
|
||||
notifyUnregister({ item, location, group });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -77,7 +103,34 @@ const getMenu: typeof menusApi.getMenu = (
|
||||
return result;
|
||||
};
|
||||
|
||||
export const useMenu = (location: string): Menu | undefined =>
|
||||
useSyncExternalStore(
|
||||
subscribe,
|
||||
() => {
|
||||
if (!menuCache.has(location)) {
|
||||
menuCache.set(location, getMenu(location));
|
||||
}
|
||||
return menuCache.get(location);
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = (
|
||||
listener: (e: MenuItemRegisteredEvent) => void,
|
||||
): Disposable => {
|
||||
registerListeners.add(listener);
|
||||
return new Disposable(() => registerListeners.delete(listener));
|
||||
};
|
||||
|
||||
export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem =
|
||||
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable => {
|
||||
unregisterListeners.add(listener);
|
||||
return new Disposable(() => unregisterListeners.delete(listener));
|
||||
};
|
||||
|
||||
export const menus: typeof menusApi = {
|
||||
registerMenuItem,
|
||||
getMenu,
|
||||
onDidRegisterMenuItem,
|
||||
onDidUnregisterMenuItem,
|
||||
};
|
||||
|
||||
@@ -24,13 +24,15 @@
|
||||
* Extensions register views as side effects at import time.
|
||||
*/
|
||||
|
||||
import React, { ReactElement } from 'react';
|
||||
import React, { ReactElement, useSyncExternalStore } from 'react';
|
||||
import type { views as viewsApi } from '@apache-superset/core';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder';
|
||||
import { Disposable } from '../models';
|
||||
|
||||
type View = viewsApi.View;
|
||||
type ViewRegisteredEvent = viewsApi.ViewRegisteredEvent;
|
||||
type ViewUnregisteredEvent = viewsApi.ViewUnregisteredEvent;
|
||||
|
||||
const viewRegistry: Map<
|
||||
string,
|
||||
@@ -39,6 +41,27 @@ const viewRegistry: Map<
|
||||
|
||||
const locationIndex: Map<string, Set<string>> = new Map();
|
||||
|
||||
const syncListeners = new Set<() => void>();
|
||||
const subscribe = (listener: () => void) => {
|
||||
syncListeners.add(listener);
|
||||
return () => syncListeners.delete(listener);
|
||||
};
|
||||
|
||||
const registerListeners = new Set<(e: ViewRegisteredEvent) => void>();
|
||||
const unregisterListeners = new Set<(e: ViewUnregisteredEvent) => void>();
|
||||
|
||||
const viewsCache = new Map<string, View[] | undefined>();
|
||||
const notifyRegister = (event: ViewRegisteredEvent) => {
|
||||
viewsCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
registerListeners.forEach(l => l(event));
|
||||
};
|
||||
const notifyUnregister = (event: ViewUnregisteredEvent) => {
|
||||
viewsCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
unregisterListeners.forEach(l => l(event));
|
||||
};
|
||||
|
||||
const registerView: typeof viewsApi.registerView = (
|
||||
view: View,
|
||||
location: string,
|
||||
@@ -51,10 +74,12 @@ const registerView: typeof viewsApi.registerView = (
|
||||
const ids = locationIndex.get(location) ?? new Set();
|
||||
ids.add(id);
|
||||
locationIndex.set(location, ids);
|
||||
notifyRegister({ view, location });
|
||||
|
||||
return new Disposable(() => {
|
||||
viewRegistry.delete(id);
|
||||
locationIndex.get(location)?.delete(id);
|
||||
notifyUnregister({ view, location });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -77,7 +102,35 @@ const getViews: typeof viewsApi.getViews = (
|
||||
.filter((c): c is View => !!c);
|
||||
};
|
||||
|
||||
export const useViews = (location: string): View[] | undefined =>
|
||||
useSyncExternalStore(
|
||||
subscribe,
|
||||
() => {
|
||||
if (!viewsCache.has(location)) {
|
||||
viewsCache.set(location, getViews(location));
|
||||
}
|
||||
return viewsCache.get(location);
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
export const onDidRegisterView: typeof viewsApi.onDidRegisterView = (
|
||||
listener: (e: ViewRegisteredEvent) => void,
|
||||
): Disposable => {
|
||||
registerListeners.add(listener);
|
||||
return new Disposable(() => registerListeners.delete(listener));
|
||||
};
|
||||
|
||||
export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = (
|
||||
listener: (e: ViewUnregisteredEvent) => void,
|
||||
): Disposable => {
|
||||
unregisterListeners.add(listener);
|
||||
return new Disposable(() => unregisterListeners.delete(listener));
|
||||
};
|
||||
|
||||
export const views: typeof viewsApi = {
|
||||
registerView,
|
||||
getViews,
|
||||
onDidRegisterView,
|
||||
onDidUnregisterView,
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ import { DataMaskStateWithId, JsonObject } from '@superset-ui/core';
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import type { RouterHistory } from '@tanstack/react-router';
|
||||
import type { History } from 'history';
|
||||
import { chart } from 'src/components/Chart/chartReducer';
|
||||
import { initSliceEntities } from 'src/dashboard/reducers/sliceEntities';
|
||||
import { getInitialState as getInitialNativeFilterState } from 'src/dashboard/reducers/nativeFilters';
|
||||
@@ -92,7 +92,7 @@ interface HydrateDashboardData extends Dashboard {
|
||||
}
|
||||
|
||||
interface HydrateDashboardParams {
|
||||
history: RouterHistory;
|
||||
history: History;
|
||||
dashboard: HydrateDashboardData;
|
||||
charts: HydrateChartData[];
|
||||
dataMask: DataMaskStateWithId;
|
||||
@@ -278,10 +278,9 @@ export const hydrateDashboard =
|
||||
// Removes the focused_chart parameter from the URL
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete(URL_PARAMS.dashboardFocusedChart.name);
|
||||
const paramString = params.toString();
|
||||
history.replace(
|
||||
`${history.location.pathname}${paramString ? `?${paramString}` : ''}`,
|
||||
);
|
||||
history.replace({
|
||||
search: params.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
// find direct link component and path from root
|
||||
|
||||
@@ -32,16 +32,14 @@ import { UPDATE_COMPONENTS } from '../../actions/dashboardLayout';
|
||||
import { AutoRefreshStatus } from '../../types/autoRefresh';
|
||||
|
||||
const mockHistoryReplace = jest.fn();
|
||||
jest.mock('@tanstack/react-router', () => ({
|
||||
...jest.requireActual('@tanstack/react-router'),
|
||||
useRouter: () => ({
|
||||
history: {
|
||||
replace: mockHistoryReplace,
|
||||
},
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useHistory: () => ({
|
||||
replace: mockHistoryReplace,
|
||||
}),
|
||||
useLocation: jest.fn(() => ({
|
||||
pathname: '/dashboard',
|
||||
searchStr: 'standalone=1',
|
||||
search: '?standalone=1',
|
||||
hash: '',
|
||||
state: undefined,
|
||||
})),
|
||||
@@ -239,10 +237,10 @@ beforeAll(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
const { useLocation } = jest.requireMock('@tanstack/react-router');
|
||||
const { useLocation } = jest.requireMock('react-router-dom');
|
||||
useLocation.mockReturnValue({
|
||||
pathname: '/dashboard',
|
||||
searchStr: 'standalone=1',
|
||||
search: '?standalone=1',
|
||||
hash: '',
|
||||
state: undefined,
|
||||
});
|
||||
@@ -1053,11 +1051,11 @@ test('should sync theme ref when navigating between dashboards', async () => {
|
||||
});
|
||||
|
||||
test('should not duplicate subdirectory prefix when toggling fullscreen', async () => {
|
||||
const { useLocation } = jest.requireMock('@tanstack/react-router');
|
||||
const { useLocation } = jest.requireMock('react-router-dom');
|
||||
// Simulate React Router with basename=/pcs: useLocation returns path relative to basename
|
||||
useLocation.mockReturnValue({
|
||||
pathname: '/dashboard',
|
||||
searchStr: 'standalone=1',
|
||||
search: '?standalone=1',
|
||||
hash: '',
|
||||
state: undefined,
|
||||
});
|
||||
@@ -1080,10 +1078,10 @@ test('should not duplicate subdirectory prefix when toggling fullscreen', async
|
||||
});
|
||||
|
||||
test('should not duplicate subdirectory prefix when entering fullscreen', async () => {
|
||||
const { useLocation } = jest.requireMock('@tanstack/react-router');
|
||||
const { useLocation } = jest.requireMock('react-router-dom');
|
||||
useLocation.mockReturnValue({
|
||||
pathname: '/dashboard',
|
||||
searchStr: '',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: undefined,
|
||||
});
|
||||
@@ -1102,11 +1100,11 @@ test('should not duplicate subdirectory prefix when entering fullscreen', async
|
||||
});
|
||||
|
||||
test('share URL should use browser-absolute pathname to preserve subdirectory prefix', () => {
|
||||
const { useLocation } = jest.requireMock('@tanstack/react-router');
|
||||
const { useLocation } = jest.requireMock('react-router-dom');
|
||||
// Router returns path without the subdirectory prefix
|
||||
useLocation.mockReturnValue({
|
||||
pathname: '/dashboard',
|
||||
searchStr: '',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: undefined,
|
||||
});
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
import type { Dispatch, ReactElement, SetStateAction } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useLocation, useRouter } from '@tanstack/react-router';
|
||||
import { replaceAppHref } from 'src/router/navigation';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { isEmpty } from 'lodash';
|
||||
@@ -75,7 +74,7 @@ export const useHeaderActionsMenu = ({
|
||||
] => {
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||
const { canExportImage } = usePermissions();
|
||||
const router = useRouter();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const directPathToChild = useSelector(
|
||||
(state: RootState) => state.dashboardState.directPathToChild,
|
||||
@@ -103,7 +102,7 @@ export const useHeaderActionsMenu = ({
|
||||
case MenuKeys.ToggleFullscreen: {
|
||||
const isCurrentlyStandalone =
|
||||
Number(getUrlParam(URL_PARAMS.standalone)) === 1;
|
||||
// Use location.pathname from the router (relative to basepath) rather than
|
||||
// Use location.pathname from React Router (relative to basename) rather than
|
||||
// window.location.pathname to avoid duplicating the subdirectory prefix when
|
||||
// history.replace prepends it again.
|
||||
const url = getDashboardUrl({
|
||||
@@ -112,7 +111,7 @@ export const useHeaderActionsMenu = ({
|
||||
hash: window.location.hash,
|
||||
standalone: isCurrentlyStandalone ? null : 1,
|
||||
});
|
||||
replaceAppHref(router, url);
|
||||
history.replace(url);
|
||||
break;
|
||||
}
|
||||
case MenuKeys.ManageEmbedded:
|
||||
@@ -129,7 +128,7 @@ export const useHeaderActionsMenu = ({
|
||||
showPropertiesModal,
|
||||
showRefreshModal,
|
||||
manageEmbedded,
|
||||
router,
|
||||
history,
|
||||
location,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -16,15 +16,10 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createMemoryHistory } from '@tanstack/react-router';
|
||||
import { StandaloneRouter } from 'src/router/StandaloneRouter';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { getExtensionsRegistry, VizType } from '@superset-ui/core';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
||||
import { useUiConfig } from 'src/components/UiConfigContext';
|
||||
import SliceHeader from '.';
|
||||
@@ -288,12 +283,12 @@ test('Should render click to edit prompt and run onExploreChart on click', async
|
||||
initialEntries: ['/superset/dashboard/1/'],
|
||||
});
|
||||
render(
|
||||
<StandaloneRouter history={history}>
|
||||
<Router history={history}>
|
||||
<SliceHeader {...props} />
|
||||
</StandaloneRouter>,
|
||||
</Router>,
|
||||
{ useRedux: true, initialState },
|
||||
);
|
||||
userEvent.hover(await screen.findByText('Vaccine Candidates per Phase'));
|
||||
userEvent.hover(screen.getByText('Vaccine Candidates per Phase'));
|
||||
expect(
|
||||
await screen.findByText('Click to edit Vaccine Candidates per Phase.'),
|
||||
).toBeInTheDocument();
|
||||
@@ -302,8 +297,7 @@ test('Should render click to edit prompt and run onExploreChart on click', async
|
||||
).toBeInTheDocument();
|
||||
|
||||
userEvent.click(screen.getByText('Vaccine Candidates per Phase'));
|
||||
// TanStack router commits navigation asynchronously.
|
||||
await waitFor(() => expect(history.location.pathname).toMatch('/explore'));
|
||||
expect(history.location.pathname).toMatch('/explore');
|
||||
});
|
||||
|
||||
test('Display cmd button in tooltip if running on MacOS', async () => {
|
||||
@@ -323,18 +317,18 @@ test('Display cmd button in tooltip if running on MacOS', async () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should not render click to edit prompt and run onExploreChart on click if supersetCanExplore=false', async () => {
|
||||
test('Should not render click to edit prompt and run onExploreChart on click if supersetCanExplore=false', () => {
|
||||
const props = createProps({ supersetCanExplore: false });
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/superset/dashboard/1/'],
|
||||
});
|
||||
render(
|
||||
<StandaloneRouter history={history}>
|
||||
<Router history={history}>
|
||||
<SliceHeader {...props} />
|
||||
</StandaloneRouter>,
|
||||
</Router>,
|
||||
{ useRedux: true, initialState },
|
||||
);
|
||||
userEvent.hover(await screen.findByText('Vaccine Candidates per Phase'));
|
||||
userEvent.hover(screen.getByText('Vaccine Candidates per Phase'));
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'Click to edit Vaccine Candidates per Phase in a new tab',
|
||||
@@ -345,18 +339,18 @@ test('Should not render click to edit prompt and run onExploreChart on click if
|
||||
expect(history.location.pathname).toMatch('/superset/dashboard');
|
||||
});
|
||||
|
||||
test('Should not render click to edit prompt and run onExploreChart on click if in edit mode', async () => {
|
||||
test('Should not render click to edit prompt and run onExploreChart on click if in edit mode', () => {
|
||||
const props = createProps({ editMode: true });
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/superset/dashboard/1/'],
|
||||
});
|
||||
render(
|
||||
<StandaloneRouter history={history}>
|
||||
<Router history={history}>
|
||||
<SliceHeader {...props} />
|
||||
</StandaloneRouter>,
|
||||
</Router>,
|
||||
{ useRedux: true, initialState },
|
||||
);
|
||||
userEvent.hover(await screen.findByText('Vaccine Candidates per Phase'));
|
||||
userEvent.hover(screen.getByText('Vaccine Candidates per Phase'));
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'Click to edit Vaccine Candidates per Phase in a new tab',
|
||||
|
||||
@@ -45,7 +45,7 @@ import { RootState } from 'src/dashboard/types';
|
||||
import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip';
|
||||
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
|
||||
import RowCountLabel from 'src/components/RowCountLabel';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
@@ -245,11 +245,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
|
||||
const renderExploreLink = (title: string) => (
|
||||
<Link
|
||||
to="/explore/"
|
||||
search={{
|
||||
dashboard_page_id: dashboardPageId,
|
||||
slice_id: String(slice.slice_id),
|
||||
}}
|
||||
to={exploreUrl}
|
||||
css={(theme: SupersetTheme) => css`
|
||||
color: ${theme.colorText};
|
||||
text-decoration: none;
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactChild, RefObject, useCallback } from 'react';
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
import { pushAppHref } from 'src/router/navigation';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Button, ModalTrigger } from '@superset-ui/core/components';
|
||||
@@ -38,9 +37,8 @@ export const ViewResultsModalTrigger = ({
|
||||
modalBody: ReactChild;
|
||||
modalRef?: RefObject<any>;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
// exploreUrl carries a query string; raw history push preserves it.
|
||||
const exploreChart = () => pushAppHref(router, exploreUrl);
|
||||
const history = useHistory();
|
||||
const exploreChart = () => history.push(exploreUrl);
|
||||
const theme = useTheme();
|
||||
const handleCloseModal = useCallback(() => {
|
||||
modalRef?.current?.close();
|
||||
|
||||
@@ -26,8 +26,7 @@ import {
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
import { pushAppHref } from 'src/router/navigation';
|
||||
import { RouteComponentProps, useHistory } from 'react-router-dom';
|
||||
import { extendedDayjs } from '@superset-ui/core/utils/dates';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
@@ -145,7 +144,8 @@ export interface SliceHeaderControlsProps {
|
||||
|
||||
crossFiltersEnabled?: boolean;
|
||||
}
|
||||
type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps;
|
||||
type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps &
|
||||
RouteComponentProps;
|
||||
|
||||
const dropdownIconsStyles = css`
|
||||
&&.anticon > .anticon:first-of-type {
|
||||
@@ -169,7 +169,7 @@ const SliceHeaderControls = (
|
||||
const [openScopingModal, scopingModal] = useCrossFiltersScopingModal(
|
||||
props.slice.slice_id,
|
||||
);
|
||||
const router = useRouter();
|
||||
const history = useHistory();
|
||||
|
||||
const queryMenuRef: RefObject<any> = useRef(null);
|
||||
const resultsMenuRef: RefObject<any> = useRef(null);
|
||||
@@ -265,8 +265,7 @@ const SliceHeaderControls = (
|
||||
domEvent.preventDefault();
|
||||
window.open(props.exploreUrl, '_blank');
|
||||
} else {
|
||||
// exploreUrl carries a query string; raw history push preserves it.
|
||||
pushAppHref(router, props.exploreUrl);
|
||||
history.push(props.exploreUrl);
|
||||
}
|
||||
break;
|
||||
case MenuKeys.ExportCsv:
|
||||
|
||||
@@ -960,3 +960,92 @@ test('Clicking the gear "Add or edit filters and controls" item opens the Filter
|
||||
|
||||
expect(await screen.findByTestId('filter-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('FilterBar with orientation=Horizontal routes to Horizontal layout instead of Vertical', async () => {
|
||||
// Migrated from the disabled Cypress spec _skip.horizontalFilterBar.test.ts:
|
||||
// proves the orientation prop selects the Horizontal subtree. The settings
|
||||
// gear (FilterBarSettings) is rendered only by Horizontal.tsx — Vertical.tsx
|
||||
// does not mount it — so its presence is a horizontal-exclusive positive
|
||||
// signal that won't false-pass if vertical heading copy is tuned. We flush
|
||||
// all pending fake timers to clear useInitialization's setTimeout
|
||||
// regardless of the production timeout literal.
|
||||
const filter = createFilter({
|
||||
id: 'NATIVE_FILTER-h1',
|
||||
name: 'Horizontal filter',
|
||||
});
|
||||
const dataMask = createDataMask(filter.id);
|
||||
const state = createStateWithFilter(filter, dataMask, {
|
||||
filterBarOrientation: FilterBarOrientation.Horizontal,
|
||||
});
|
||||
|
||||
render(<FilterBar orientation={FilterBarOrientation.Horizontal} />, {
|
||||
initialState: state,
|
||||
useDnd: true,
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('img', { name: 'setting' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('FilterBar with orientation=Horizontal and no filters shows empty state alongside default actions', async () => {
|
||||
// Covers the second half of sc-107387 task #107390 ("show all default
|
||||
// actions in horizontal mode"). The original Cypress spec asserted four
|
||||
// affordances render when the bar is horizontal with no filters: the
|
||||
// empty-state copy, the settings gear, the action-buttons block, and the
|
||||
// create-filter entry inside the gear menu. The dropdown contents are
|
||||
// already covered by FilterBarSettings.test.tsx; here we keep scope to
|
||||
// the layout-level affordances that are exclusive to Horizontal.tsx.
|
||||
// Reload-persistence (the rest of #107390) is out of RTL scope and stays
|
||||
// queued for Playwright.
|
||||
const state = {
|
||||
...stateWithoutNativeFilters,
|
||||
dashboardInfo: {
|
||||
id: 1,
|
||||
dash_edit_perm: true,
|
||||
metadata: {
|
||||
native_filter_configuration: [],
|
||||
filterBarOrientation: FilterBarOrientation.Horizontal,
|
||||
},
|
||||
},
|
||||
dashboardState: {
|
||||
...stateWithoutNativeFilters.dashboardState,
|
||||
activeTabs: ['ROOT_ID'],
|
||||
},
|
||||
nativeFilters: { filters: {}, filtersState: {} },
|
||||
};
|
||||
|
||||
render(<FilterBar orientation={FilterBarOrientation.Horizontal} />, {
|
||||
initialState: state,
|
||||
useDnd: true,
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('horizontal-filterbar-empty')).toHaveTextContent(
|
||||
'No filters are currently added to this dashboard.',
|
||||
);
|
||||
expect(screen.getByRole('img', { name: 'setting' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('filterbar-action-buttons')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('FilterBar with orientation=Vertical renders Vertical layout (sanity counterpart to the horizontal routing test)', () => {
|
||||
// Paired control for the routing test above: with Vertical orientation,
|
||||
// the settings gear must NOT be present (Vertical.tsx does not render
|
||||
// FilterBarSettings). Confirms the routing signal is horizontal-exclusive,
|
||||
// not a coincidence of when timers fire.
|
||||
const props = createClosedBarProps();
|
||||
renderFilterBar(props);
|
||||
expect(screen.getByText('Filters and controls')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('img', { name: 'setting' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* 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 { Preset } from '@superset-ui/core';
|
||||
import type { DataMaskStateWithId } from '@superset-ui/core';
|
||||
import type {
|
||||
DropdownContainerProps,
|
||||
DropdownItem,
|
||||
} from '@superset-ui/core/components/DropdownContainer';
|
||||
import { SelectFilterPlugin } from 'src/filters/components';
|
||||
import { FilterBarOrientation } from 'src/dashboard/types';
|
||||
import { act, render, waitFor, within } from 'spec/helpers/testing-library';
|
||||
import { createSelectNativeFilter } from 'spec/fixtures/mockNativeFilters';
|
||||
import FilterControls from './FilterControls';
|
||||
|
||||
// Capture every props snapshot DropdownContainer receives, plus the latest
|
||||
// onOverflowingStateChange callback. Tests drive overflow by invoking the
|
||||
// callback and then assert against the *next* captured props snapshot —
|
||||
// these are the values FilterControls itself computed (dropdownTriggerCount,
|
||||
// dropdownContent, items) so assertions exercise real production logic
|
||||
// rather than props the test handed in directly.
|
||||
const dropdownContainerProps: DropdownContainerProps[] = [];
|
||||
const callbackRef: {
|
||||
current:
|
||||
| ((s: { overflowed: string[]; notOverflowed: string[] }) => void)
|
||||
| null;
|
||||
} = { current: null };
|
||||
|
||||
// Mock the DropdownContainer subpath rather than the barrel
|
||||
// `@superset-ui/core/components` — mocking the barrel triggers a
|
||||
// circular re-export chain at requireActual time
|
||||
// (LabeledErrorBoundInput → ActionButton is undefined at that point).
|
||||
// The barrel's `export { DropdownContainer } from './DropdownContainer'`
|
||||
// resolves to this subpath, so the mock is picked up transparently.
|
||||
jest.mock('@superset-ui/core/components/DropdownContainer', () => {
|
||||
const React = jest.requireActual('react');
|
||||
const MockDropdownContainer = React.forwardRef(
|
||||
(props: DropdownContainerProps, ref: React.Ref<unknown>) => {
|
||||
dropdownContainerProps.push(props);
|
||||
callbackRef.current = props.onOverflowingStateChange ?? null;
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
open: jest.fn(),
|
||||
close: jest.fn(),
|
||||
}));
|
||||
return (
|
||||
<div data-test="dropdown-container-mock">
|
||||
<div data-test="dropdown-items">
|
||||
{props.items.map((item: DropdownItem) => (
|
||||
<div key={item.id} data-test="dropdown-item">
|
||||
{item.element}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div data-test="dropdown-trigger-text">
|
||||
{props.dropdownTriggerText}
|
||||
</div>
|
||||
<div data-test="dropdown-trigger-count">
|
||||
{props.dropdownTriggerCount}
|
||||
</div>
|
||||
{props.dropdownContent && (
|
||||
<div data-test="dropdown-content-mock">
|
||||
{props.dropdownContent([])}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
return { __esModule: true, DropdownContainer: MockDropdownContainer };
|
||||
});
|
||||
|
||||
class OverflowTestPreset extends Preset {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'FilterControls overflow test preset',
|
||||
plugins: [new SelectFilterPlugin().configure({ key: 'filter_select' })],
|
||||
});
|
||||
}
|
||||
}
|
||||
new OverflowTestPreset().register();
|
||||
|
||||
// Tabless dashboard layout ⇒ useSelectFiltersInScope returns all filters in
|
||||
// scope without needing to model tab parentage.
|
||||
const buildHorizontalState = (
|
||||
filters: ReturnType<typeof createSelectNativeFilter>[],
|
||||
) => ({
|
||||
dashboardInfo: {
|
||||
id: 1,
|
||||
dash_edit_perm: true,
|
||||
filterBarOrientation: FilterBarOrientation.Horizontal,
|
||||
metadata: {
|
||||
native_filter_configuration: filters,
|
||||
},
|
||||
},
|
||||
dashboardLayout: {
|
||||
present: {
|
||||
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: [] },
|
||||
},
|
||||
past: [],
|
||||
future: [],
|
||||
},
|
||||
dashboardState: {
|
||||
sliceIds: [],
|
||||
activeTabs: ['ROOT_ID'],
|
||||
},
|
||||
charts: {},
|
||||
nativeFilters: {
|
||||
filters: filters.reduce(
|
||||
(acc, f) => ({ ...acc, [f.id]: f }),
|
||||
{} as Record<string, ReturnType<typeof createSelectNativeFilter>>,
|
||||
),
|
||||
filtersState: {},
|
||||
},
|
||||
dataMask: {},
|
||||
sliceEntities: { slices: {} },
|
||||
datasources: {},
|
||||
});
|
||||
|
||||
const buildDataMaskSelected = (
|
||||
filters: ReturnType<typeof createSelectNativeFilter>[],
|
||||
withValueIds: string[] = [],
|
||||
): DataMaskStateWithId =>
|
||||
filters.reduce(
|
||||
(acc, f) => ({
|
||||
...acc,
|
||||
[f.id]: {
|
||||
id: f.id,
|
||||
filterState: {
|
||||
value: withValueIds.includes(f.id) ? ['set'] : null,
|
||||
},
|
||||
extraFormData: {},
|
||||
},
|
||||
}),
|
||||
{} as DataMaskStateWithId,
|
||||
);
|
||||
|
||||
const renderHorizontal = (
|
||||
filters: ReturnType<typeof createSelectNativeFilter>[],
|
||||
dataMaskSelected: DataMaskStateWithId,
|
||||
) =>
|
||||
render(
|
||||
<FilterControls
|
||||
dataMaskSelected={dataMaskSelected}
|
||||
onFilterSelectionChange={jest.fn()}
|
||||
onPendingCustomizationDataMaskChange={jest.fn()}
|
||||
chartCustomizationValues={[]}
|
||||
/>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: buildHorizontalState(filters),
|
||||
},
|
||||
);
|
||||
|
||||
const latestProps = () =>
|
||||
dropdownContainerProps[dropdownContainerProps.length - 1];
|
||||
|
||||
const fireOverflow = (overflowed: string[], notOverflowed: string[]) => {
|
||||
if (!callbackRef.current) {
|
||||
throw new Error('onOverflowingStateChange callback not captured');
|
||||
}
|
||||
act(() => {
|
||||
callbackRef.current!({ overflowed, notOverflowed });
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dropdownContainerProps.length = 0;
|
||||
callbackRef.current = null;
|
||||
});
|
||||
|
||||
test('horizontal FilterControls hands every filter to DropdownContainer as an item', async () => {
|
||||
const filters = [
|
||||
createSelectNativeFilter('NATIVE_FILTER-1', 'country'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-2', 'region'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-3', 'city'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-4', 'zip'),
|
||||
];
|
||||
|
||||
renderHorizontal(filters, buildDataMaskSelected(filters));
|
||||
|
||||
await waitFor(() => expect(latestProps()).toBeTruthy());
|
||||
|
||||
expect(latestProps().items.map((i: DropdownItem) => i.id)).toEqual([
|
||||
'NATIVE_FILTER-1',
|
||||
'NATIVE_FILTER-2',
|
||||
'NATIVE_FILTER-3',
|
||||
'NATIVE_FILTER-4',
|
||||
]);
|
||||
// dropdownTriggerText is the production string FilterControls passes in.
|
||||
expect(latestProps().dropdownTriggerText).toBe('More filters');
|
||||
});
|
||||
|
||||
test('with no overflow callback fired, dropdown trigger count is 0 and content is empty', async () => {
|
||||
const filters = [
|
||||
createSelectNativeFilter('NATIVE_FILTER-1', 'country'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-2', 'region'),
|
||||
];
|
||||
|
||||
renderHorizontal(
|
||||
filters,
|
||||
buildDataMaskSelected(filters, ['NATIVE_FILTER-1']),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(latestProps()).toBeTruthy());
|
||||
|
||||
expect(latestProps().dropdownTriggerCount).toBe(0);
|
||||
// FilterControls only supplies dropdownContent when something overflowed.
|
||||
expect(latestProps().dropdownContent).toBeUndefined();
|
||||
});
|
||||
|
||||
test('firing overflow with two filters that have values increments the trigger count to 2', async () => {
|
||||
const filters = [
|
||||
createSelectNativeFilter('NATIVE_FILTER-1', 'country'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-2', 'region'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-3', 'city'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-4', 'zip'),
|
||||
];
|
||||
|
||||
renderHorizontal(
|
||||
filters,
|
||||
// Mark the two we plan to overflow as having values; the production
|
||||
// selector activeOverflowedFiltersInScope filters on dataMask.filterState.value.
|
||||
buildDataMaskSelected(filters, ['NATIVE_FILTER-3', 'NATIVE_FILTER-4']),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(callbackRef.current).toBeTruthy());
|
||||
|
||||
fireOverflow(
|
||||
['NATIVE_FILTER-3', 'NATIVE_FILTER-4'],
|
||||
['NATIVE_FILTER-1', 'NATIVE_FILTER-2'],
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(latestProps().dropdownTriggerCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('firing overflow with no active values keeps trigger count at 0 but supplies dropdownContent', async () => {
|
||||
// Reinforces the activeOverflowedFiltersInScope branch in
|
||||
// FilterControls.tsx: count is the *active* (value-bearing) subset of
|
||||
// overflowed filters, not the raw overflowed count. If the production
|
||||
// memo regressed to use overflowedFiltersInScope.length, this fails.
|
||||
const filters = [
|
||||
createSelectNativeFilter('NATIVE_FILTER-1', 'country'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-2', 'region'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-3', 'city'),
|
||||
];
|
||||
|
||||
renderHorizontal(filters, buildDataMaskSelected(filters));
|
||||
|
||||
await waitFor(() => expect(callbackRef.current).toBeTruthy());
|
||||
|
||||
fireOverflow(['NATIVE_FILTER-2', 'NATIVE_FILTER-3'], ['NATIVE_FILTER-1']);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(latestProps().dropdownContent).toBeInstanceOf(Function);
|
||||
});
|
||||
expect(latestProps().dropdownTriggerCount).toBe(0);
|
||||
});
|
||||
|
||||
test('all 12 overflowed filters are reachable through dropdownContent', async () => {
|
||||
// Substitutes for the disabled Cypress "scroll within overflow" assertion:
|
||||
// jsdom has no real layout/scrolling, so we instead prove every overflowed
|
||||
// filter renders inside the dropdown panel.
|
||||
const filters = Array.from({ length: 12 }, (_, i) =>
|
||||
createSelectNativeFilter(`NATIVE_FILTER-${i + 1}`, `filter_${i + 1}`),
|
||||
);
|
||||
|
||||
renderHorizontal(filters, buildDataMaskSelected(filters));
|
||||
|
||||
await waitFor(() => expect(callbackRef.current).toBeTruthy());
|
||||
|
||||
fireOverflow(
|
||||
filters.map(f => f.id),
|
||||
[],
|
||||
);
|
||||
|
||||
// dropdownContent renders FiltersDropdownContent, which renders each
|
||||
// overflowed filter through the renderer prop. Asserting on identity
|
||||
// (not just count) catches a regression that rendered the wrong subset
|
||||
// of filters in the dropdown — e.g. all `filtersInScope` instead of
|
||||
// the overflowed slice.
|
||||
const { findByTestId } = within(document.body);
|
||||
const contentSlot = await findByTestId('dropdown-content-mock');
|
||||
await waitFor(() => {
|
||||
const names = within(contentSlot).getAllByTestId('filter-control-name');
|
||||
expect(names.map(n => n.textContent)).toEqual(filters.map(f => f.name));
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ import { FilterBarOrientation, RootState } from 'src/dashboard/types';
|
||||
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
|
||||
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation, useRouter } from '@tanstack/react-router';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { removeDataMask, updateDataMask } from 'src/dataMask/actions';
|
||||
import {
|
||||
getRisonFilterParam,
|
||||
@@ -121,8 +121,8 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
|
||||
state => state.dataMask,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
const router = useRouter();
|
||||
const searchStr = useLocation({ select: location => location.searchStr });
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const chartIds = useChartIds();
|
||||
const chartLayoutItems = useChartLayoutItems();
|
||||
const verboseMaps = useChartsVerboseMaps();
|
||||
@@ -146,7 +146,7 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
|
||||
// programmatic history.replace).
|
||||
useEffect(() => {
|
||||
setActiveUrlFilters(getUrlFilterIndicators());
|
||||
}, [searchStr]);
|
||||
}, [location.search]);
|
||||
|
||||
const handleRemoveUrlFilter = useCallback(
|
||||
(filterToRemove: UrlFilterIndicator) => {
|
||||
@@ -158,7 +158,7 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
|
||||
const remaining = currentFilters.filter(
|
||||
f => getUrlFilterIdentity(f) !== removeId,
|
||||
);
|
||||
updateUrlWithUnmatchedFilters(remaining, router.history);
|
||||
updateUrlWithUnmatchedFilters(remaining, history);
|
||||
setActiveUrlFilters(prev =>
|
||||
prev.filter(f => getUrlFilterIdentity(f.filter) !== removeId),
|
||||
);
|
||||
@@ -175,7 +175,7 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, router],
|
||||
[dispatch, history],
|
||||
);
|
||||
|
||||
const urlFiltersComponent = useMemo(() => {
|
||||
|
||||
@@ -16,9 +16,26 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { NativeFilterType } from '@superset-ui/core';
|
||||
import { NativeFilterType, Preset } from '@superset-ui/core';
|
||||
import type { Filter } from '@superset-ui/core';
|
||||
import { SelectFilterPlugin } from 'src/filters/components';
|
||||
import { FilterBarOrientation } from 'src/dashboard/types';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import { createSelectNativeFilter } from 'spec/fixtures/mockNativeFilters';
|
||||
import HorizontalBar from './Horizontal';
|
||||
import type { HorizontalBarProps } from './types';
|
||||
|
||||
// Register the select filter plugin once so FilterControl can render the
|
||||
// filter name without throwing when the plugin registry is consulted.
|
||||
class HorizontalFilterBarTestPreset extends Preset {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'Horizontal filter bar test preset',
|
||||
plugins: [new SelectFilterPlugin().configure({ key: 'filter_select' })],
|
||||
});
|
||||
}
|
||||
}
|
||||
new HorizontalFilterBarTestPreset().register();
|
||||
|
||||
const defaultProps = {
|
||||
actions: null,
|
||||
@@ -32,7 +49,7 @@ const defaultProps = {
|
||||
onPendingCustomizationDataMaskChange: jest.fn(),
|
||||
};
|
||||
|
||||
const renderWrapper = (overrideProps?: Record<string, any>) =>
|
||||
const renderWrapper = (overrideProps?: Partial<HorizontalBarProps>) =>
|
||||
waitFor(() =>
|
||||
render(<HorizontalBar {...defaultProps} {...overrideProps} />, {
|
||||
useRedux: true,
|
||||
@@ -60,11 +77,13 @@ test('should render', async () => {
|
||||
|
||||
test('should not render the empty message', async () => {
|
||||
await renderWrapper({
|
||||
// Intentionally minimal — Horizontal only reads filterValues.length
|
||||
// here, so the missing required Filter fields would never be read.
|
||||
filterValues: [
|
||||
{
|
||||
id: 'test',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
},
|
||||
} as unknown as Filter,
|
||||
],
|
||||
});
|
||||
expect(
|
||||
@@ -92,3 +111,133 @@ test('should render the loading icon', async () => {
|
||||
});
|
||||
expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// --- Tests migrated from disabled Cypress spec
|
||||
// `_skip.horizontalFilterBar.test.ts` (sc-107387). ---
|
||||
|
||||
const buildStateWithFilters = (
|
||||
filters: ReturnType<typeof createSelectNativeFilter>[],
|
||||
) => ({
|
||||
dashboardState: {
|
||||
sliceIds: [],
|
||||
activeTabs: ['ROOT_ID'],
|
||||
},
|
||||
dashboardInfo: {
|
||||
id: 1,
|
||||
dash_edit_perm: true,
|
||||
filterBarOrientation: FilterBarOrientation.Horizontal,
|
||||
metadata: {
|
||||
native_filter_configuration: filters,
|
||||
},
|
||||
},
|
||||
dashboardLayout: {
|
||||
present: {
|
||||
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: [] },
|
||||
},
|
||||
past: [],
|
||||
future: [],
|
||||
},
|
||||
charts: {},
|
||||
nativeFilters: {
|
||||
filters: filters.reduce(
|
||||
(acc, f) => ({ ...acc, [f.id]: f }),
|
||||
{} as Record<string, ReturnType<typeof createSelectNativeFilter>>,
|
||||
),
|
||||
filtersState: {},
|
||||
},
|
||||
dataMask: filters.reduce(
|
||||
(acc, f) => ({
|
||||
...acc,
|
||||
[f.id]: { id: f.id, filterState: { value: null }, extraFormData: {} },
|
||||
}),
|
||||
{} as Record<string, unknown>,
|
||||
),
|
||||
sliceEntities: { slices: {} },
|
||||
datasources: {},
|
||||
});
|
||||
|
||||
const renderWithFilters = (
|
||||
filters: ReturnType<typeof createSelectNativeFilter>[],
|
||||
overrideProps?: Partial<HorizontalBarProps>,
|
||||
) =>
|
||||
render(<HorizontalBar {...defaultProps} {...overrideProps} />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: buildStateWithFilters(filters),
|
||||
});
|
||||
|
||||
test('renders default actions slot, settings gear, and empty message together in horizontal mode', async () => {
|
||||
const sentinelActions = (
|
||||
<button type="button" data-test="sentinel-actions">
|
||||
apply
|
||||
</button>
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
render(
|
||||
<HorizontalBar
|
||||
{...defaultProps}
|
||||
actions={sentinelActions}
|
||||
filterValues={[]}
|
||||
/>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
dashboardState: { sliceIds: [] },
|
||||
dashboardInfo: {
|
||||
id: 1,
|
||||
dash_edit_perm: true,
|
||||
filterBarOrientation: FilterBarOrientation.Horizontal,
|
||||
},
|
||||
dashboardLayout: { present: {}, past: [], future: [] },
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('No filters are currently added to this dashboard.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'setting' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('sentinel-actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders all native filters supplied via filterValues in horizontal mode', async () => {
|
||||
const filters = [
|
||||
createSelectNativeFilter('NATIVE_FILTER-1', 'test_1', 'country_name'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-2', 'test_2', 'country_code'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-3', 'test_3', 'region'),
|
||||
];
|
||||
|
||||
renderWithFilters(filters, { filterValues: filters });
|
||||
|
||||
await waitFor(() => {
|
||||
const filterNames = screen.getAllByTestId('filter-control-name');
|
||||
expect(filterNames).toHaveLength(3);
|
||||
});
|
||||
|
||||
['test_1', 'test_2', 'test_3'].forEach(name => {
|
||||
expect(screen.getByText(name)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('omits the empty message when at least one filter value is supplied', async () => {
|
||||
// Companion to the "renders all native filters" test above: the migrated
|
||||
// Cypress "display newly added filter" scenario reduces, at this layer, to
|
||||
// proving that supplying a filter value flips off the empty state. The
|
||||
// upstream user flow (open edit modal, add filter, save) is integration
|
||||
// territory and not covered here.
|
||||
const filters = [
|
||||
createSelectNativeFilter('NATIVE_FILTER-1', 'just_added', 'country_name'),
|
||||
];
|
||||
|
||||
renderWithFilters(filters, { filterValues: filters });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('just_added')).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.queryByText('No filters are currently added to this dashboard.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -24,15 +24,9 @@
|
||||
* - the chip list must react to URL changes (back/forward navigation or
|
||||
* a programmatic history.replace), not snapshot the URL at mount.
|
||||
*/
|
||||
import { createMemoryHistory } from '@tanstack/react-router';
|
||||
import { StandaloneRouter } from 'src/router/StandaloneRouter';
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { act, render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import { REMOVE_DATA_MASK, UPDATE_DATA_MASK } from 'src/dataMask/actions';
|
||||
import { RISON_UNMATCHED_DATAMASK_ID } from 'src/dashboard/util/risonFilters';
|
||||
import UrlFiltersVertical from './Vertical';
|
||||
@@ -45,7 +39,7 @@ jest.mock('react-redux', () => ({
|
||||
|
||||
const seedUrl = (search: string) => {
|
||||
// jsdom doesn't navigate, so set both window.location (read by
|
||||
// getRisonFilterParam) and the router's in-memory history.
|
||||
// getRisonFilterParam) and react-router's in-memory history.
|
||||
window.history.replaceState({}, '', `/superset/dashboard/1/${search}`);
|
||||
};
|
||||
|
||||
@@ -55,9 +49,9 @@ const renderAt = (search: string) => {
|
||||
initialEntries: [`/superset/dashboard/1/${search}`],
|
||||
});
|
||||
const utils = render(
|
||||
<StandaloneRouter history={history}>
|
||||
<Router history={history}>
|
||||
<UrlFiltersVertical />
|
||||
</StandaloneRouter>,
|
||||
</Router>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
return { ...utils, history };
|
||||
@@ -126,7 +120,7 @@ test('removing the last chip dispatches removeDataMask, not an empty update', as
|
||||
expect(updateCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('chip list re-renders when the URL changes (popstate/programmatic nav)', async () => {
|
||||
test('chip list re-renders when the URL changes (popstate/programmatic nav)', () => {
|
||||
const { history } = renderAt('?f=(region:EMEA)');
|
||||
|
||||
expect(screen.getByText('region')).toBeInTheDocument();
|
||||
@@ -139,8 +133,7 @@ test('chip list re-renders when the URL changes (popstate/programmatic nav)', as
|
||||
history.replace('/superset/dashboard/1/?f=(priority:high)');
|
||||
});
|
||||
|
||||
// The router commits location updates asynchronously.
|
||||
await waitFor(() => expect(screen.getByText('priority')).toBeInTheDocument());
|
||||
expect(screen.getByText('priority')).toBeInTheDocument();
|
||||
expect(screen.getByText('high')).toBeInTheDocument();
|
||||
expect(screen.queryByText('region')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocation, useRouter } from '@tanstack/react-router';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { QueryObjectFilterClause } from '@superset-ui/core';
|
||||
import { removeDataMask, updateDataMask } from 'src/dataMask/actions';
|
||||
import {
|
||||
@@ -38,8 +38,8 @@ import UrlFiltersVerticalCollapse from './VerticalCollapse';
|
||||
|
||||
const UrlFiltersVertical = () => {
|
||||
const dispatch = useDispatch();
|
||||
const router = useRouter();
|
||||
const searchStr = useLocation({ select: location => location.searchStr });
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const [urlFilters, setUrlFilters] = useState<UrlFilterIndicator[]>(() =>
|
||||
getUrlFilterIndicators(),
|
||||
);
|
||||
@@ -48,7 +48,7 @@ const UrlFiltersVertical = () => {
|
||||
// programmatic history.replace).
|
||||
useEffect(() => {
|
||||
setUrlFilters(getUrlFilterIndicators());
|
||||
}, [searchStr]);
|
||||
}, [location.search]);
|
||||
|
||||
const handleRemoveFilter = useCallback(
|
||||
(filterToRemove: UrlFilterIndicator) => {
|
||||
@@ -61,7 +61,7 @@ const UrlFiltersVertical = () => {
|
||||
f => getUrlFilterIdentity(f) !== removeId,
|
||||
);
|
||||
|
||||
updateUrlWithUnmatchedFilters(remaining, router.history);
|
||||
updateUrlWithUnmatchedFilters(remaining, history);
|
||||
setUrlFilters(prev =>
|
||||
prev.filter(f => getUrlFilterIdentity(f.filter) !== removeId),
|
||||
);
|
||||
@@ -78,7 +78,7 @@ const UrlFiltersVertical = () => {
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, router],
|
||||
[dispatch, history],
|
||||
);
|
||||
|
||||
if (!urlFilters.length) {
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Constants } from '@superset-ui/core/components';
|
||||
import { useRouter, type RouterHistory } from '@tanstack/react-router';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { updateDataMask, removeDataMask } from 'src/dataMask/actions';
|
||||
import {
|
||||
saveChartCustomization,
|
||||
@@ -55,6 +55,7 @@ import { useImmer } from 'use-immer';
|
||||
import { isEmpty, isEqual, debounce } from 'lodash';
|
||||
import { getInitialDataMask } from 'src/dataMask/reducer';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { applicationRoot } from 'src/utils/getBootstrapData';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { useTabId } from 'src/hooks/useTabId';
|
||||
import { logEvent } from 'src/logger/actions';
|
||||
@@ -95,7 +96,7 @@ const EMPTY_DATA_MASK_RECORD: Record<string, DataMask> = {};
|
||||
|
||||
const publishDataMask = debounce(
|
||||
async (
|
||||
history: RouterHistory,
|
||||
history,
|
||||
dashboardId,
|
||||
updateKey,
|
||||
dataMaskSelected: DataMaskStateWithId,
|
||||
@@ -144,10 +145,15 @@ const publishDataMask = debounce(
|
||||
// replace params only when current page is /superset/dashboard
|
||||
// this prevents a race condition between updating filters and navigating to Explore
|
||||
if (window.location.pathname.includes('/superset/dashboard')) {
|
||||
// The router's history is the raw browser history (no basepath
|
||||
// handling), so the full window pathname — application root
|
||||
// included — is replaced verbatim.
|
||||
const replacementPathname = window.location.pathname;
|
||||
// The history API is part of React router and understands that a basename may exist.
|
||||
// Internally it treats all paths as if they are relative to the root and appends
|
||||
// it when necessary. We strip any prefix so that history.replace adds it back and doesn't
|
||||
// double it up.
|
||||
const appRoot = applicationRoot();
|
||||
let replacementPathname = window.location.pathname;
|
||||
if (appRoot !== '/' && replacementPathname.startsWith(appRoot)) {
|
||||
replacementPathname = replacementPathname.substring(appRoot.length);
|
||||
}
|
||||
// Manually reconstruct the search string to preserve Rison filter encoding
|
||||
let searchString = newParams.toString();
|
||||
if (rawRisonFilterValue) {
|
||||
@@ -155,9 +161,10 @@ const publishDataMask = debounce(
|
||||
searchString = `${searchString}${separator}f=${rawRisonFilterValue}`;
|
||||
}
|
||||
|
||||
history.replace(
|
||||
`${replacementPathname}${searchString ? `?${searchString}` : ''}`,
|
||||
);
|
||||
history.replace({
|
||||
pathname: replacementPathname,
|
||||
search: searchString,
|
||||
});
|
||||
}
|
||||
},
|
||||
Constants.SLOW_DEBOUNCE,
|
||||
@@ -168,7 +175,7 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
verticalConfig,
|
||||
hidden = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const history = useHistory();
|
||||
const dataMaskApplied: DataMaskStateWithId = useAllAppliedDataMask();
|
||||
|
||||
const [dataMaskSelected, setDataMaskSelected] =
|
||||
@@ -399,16 +406,10 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
useEffect(() => {
|
||||
// embedded users can't persist filter combinations
|
||||
if (user?.userId) {
|
||||
publishDataMask(
|
||||
router.history,
|
||||
dashboardId,
|
||||
updateKey,
|
||||
dataMaskApplied,
|
||||
tabId,
|
||||
);
|
||||
publishDataMask(history, dashboardId, updateKey, dataMaskApplied, tabId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboardId, dataMaskAppliedText, router, updateKey, tabId]);
|
||||
}, [dashboardId, dataMaskAppliedText, history, updateKey, tabId]);
|
||||
|
||||
const pendingChartCustomizations = useSelector<
|
||||
RootState,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { createContext, lazy, FC, useEffect, useMemo, useRef } from 'react';
|
||||
import { Global } from '@emotion/react';
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -128,7 +128,7 @@ const selectActiveFilters = createSelector(
|
||||
export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const router = useRouter();
|
||||
const history = useHistory();
|
||||
const dashboardPageId = useMemo(() => nanoid(), []);
|
||||
const hasDashboardInfoInitiated = useSelector<RootState, boolean>(
|
||||
({ dashboardInfo }) =>
|
||||
@@ -267,14 +267,14 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
|
||||
// Rewrite the URL to drop matched filters in a single step, keeping
|
||||
// only unmatched ones (and prettifying their encoding). Going
|
||||
// through the router's history keeps its location.search in
|
||||
// through react-router's history keeps `history.location.search` in
|
||||
// sync so `publishDataMask` doesn't re-emit the original `f=`.
|
||||
const matchedCount =
|
||||
risonFilters.length - injectionResult.unmatchedFilters.length;
|
||||
if (matchedCount > 0) {
|
||||
updateUrlWithUnmatchedFilters(
|
||||
injectionResult.unmatchedFilters,
|
||||
router.history,
|
||||
history,
|
||||
);
|
||||
}
|
||||
if (injectionResult.unmatchedFilters.length > 0) {
|
||||
@@ -289,7 +289,7 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
}
|
||||
dispatch(
|
||||
hydrateDashboard({
|
||||
history: router.history,
|
||||
history,
|
||||
dashboard: dashboard!,
|
||||
charts: charts!,
|
||||
activeTabs: activeTabs ?? null,
|
||||
|
||||
@@ -353,10 +353,10 @@ test('updateUrlWithUnmatchedFilters goes through history when supplied', () => {
|
||||
);
|
||||
|
||||
expect(replace).toHaveBeenCalledTimes(1);
|
||||
const href = replace.mock.calls[0][0];
|
||||
expect(href).toMatch(/^\/superset\/dashboard\/1\/\?/);
|
||||
expect(href).toContain('f=');
|
||||
expect(href).toContain('region');
|
||||
const call = replace.mock.calls[0][0];
|
||||
expect(call.pathname).toBe('/superset/dashboard/1/');
|
||||
expect(call.search).toContain('f=');
|
||||
expect(call.search).toContain('region');
|
||||
|
||||
// Restore.
|
||||
window.history.replaceState({}, '', originalLocation);
|
||||
@@ -370,7 +370,7 @@ test('updateUrlWithUnmatchedFilters drops f= when no unmatched remain', () => {
|
||||
updateUrlWithUnmatchedFilters([], { replace });
|
||||
|
||||
expect(replace).toHaveBeenCalledTimes(1);
|
||||
expect(replace.mock.calls[0][0]).toBe('/superset/dashboard/1/');
|
||||
expect(replace.mock.calls[0][0].search).toBe('');
|
||||
|
||||
window.history.replaceState({}, '', originalLocation);
|
||||
});
|
||||
@@ -382,20 +382,16 @@ test('updateUrlWithUnmatchedFilters cleanup is observable by history readers', (
|
||||
// history.location.search stale, causing publishDataMask to re-append
|
||||
// the original f= on the next interaction.
|
||||
//
|
||||
// Stand in for the router's history with a fake whose `.location`
|
||||
// Stand in for react-router's history with a fake whose `.location`
|
||||
// updates synchronously when .replace is called — same contract as
|
||||
// the router history's replace.
|
||||
// react-router-dom's history.replace.
|
||||
const fakeHistory = {
|
||||
location: {
|
||||
pathname: '/superset/dashboard/1/',
|
||||
search: '?f=(country:USA)',
|
||||
},
|
||||
replace(href: string) {
|
||||
const [pathname, search = ''] = href.split('?');
|
||||
this.location = {
|
||||
pathname,
|
||||
search: search ? `?${search}` : '',
|
||||
};
|
||||
replace(next: { pathname: string; search: string }) {
|
||||
this.location = next;
|
||||
},
|
||||
};
|
||||
const originalLocation = window.location.href;
|
||||
|
||||
@@ -318,15 +318,15 @@ export function risonFiltersToString(filters: RisonFilter[]): string {
|
||||
}
|
||||
|
||||
interface ReplaceHistory {
|
||||
replace(href: string): void;
|
||||
replace(location: { pathname: string; search: string }): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the URL to remove successfully matched filters, keeping only unmatched ones.
|
||||
* When a router history is supplied (e.g. `useRouter().history`), the update
|
||||
* goes through it so that components reading the router location (e.g.
|
||||
* `publishDataMask` in the filter bar) see the new search string. Otherwise
|
||||
* falls back to a raw `window.history.replaceState`.
|
||||
* When a react-router history is supplied, the update goes through it so that
|
||||
* components reading from `history.location` (e.g. `publishDataMask` in the
|
||||
* filter bar) see the new search string. Otherwise falls back to a raw
|
||||
* `window.history.replaceState`.
|
||||
*/
|
||||
export function updateUrlWithUnmatchedFilters(
|
||||
unmatchedFilters: RisonFilter[],
|
||||
@@ -358,7 +358,10 @@ export function updateUrlWithUnmatchedFilters(
|
||||
currentUrl.toString(),
|
||||
);
|
||||
if (history) {
|
||||
history.replace(`${currentUrl.pathname}${currentUrl.search}`);
|
||||
history.replace({
|
||||
pathname: currentUrl.pathname,
|
||||
search: currentUrl.search,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to update URL with unmatched filters:', error);
|
||||
|
||||
@@ -20,13 +20,7 @@ import 'src/public-path';
|
||||
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import {
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
RouterProvider,
|
||||
} from '@tanstack/react-router';
|
||||
import { parseSearch, stringifySearch } from 'src/router/searchParams';
|
||||
import { BrowserRouter as Router, Route } from 'react-router-dom';
|
||||
import { Global } from '@emotion/react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { makeApi } from '@superset-ui/core';
|
||||
@@ -122,29 +116,13 @@ const EmbeddedRoute = () => (
|
||||
</EmbeddedContextProviders>
|
||||
);
|
||||
|
||||
const embeddedRootRoute = createRootRoute();
|
||||
const embeddedRouter = createRouter({
|
||||
routeTree: embeddedRootRoute.addChildren([
|
||||
// todo (embedded) remove this route after uuids are deployed
|
||||
createRoute({
|
||||
getParentRoute: () => embeddedRootRoute,
|
||||
path: '/dashboard/$idOrSlug/embedded',
|
||||
component: EmbeddedRoute,
|
||||
}),
|
||||
createRoute({
|
||||
getParentRoute: () => embeddedRootRoute,
|
||||
path: '/embedded/$uuid',
|
||||
component: EmbeddedRoute,
|
||||
}),
|
||||
]),
|
||||
basepath: applicationRoot() || undefined,
|
||||
parseSearch,
|
||||
stringifySearch,
|
||||
trailingSlash: 'preserve',
|
||||
defaultPreload: false,
|
||||
});
|
||||
|
||||
const EmbeddedApp = () => <RouterProvider router={embeddedRouter} />;
|
||||
const EmbeddedApp = () => (
|
||||
<Router basename={applicationRoot()}>
|
||||
{/* todo (embedded) remove this line after uuids are deployed */}
|
||||
<Route path="/dashboard/:idOrSlug/embedded/" component={EmbeddedRoute} />
|
||||
<Route path="/embedded/:uuid/" component={EmbeddedRoute} />
|
||||
</Router>
|
||||
);
|
||||
|
||||
const appMountPoint = document.getElementById('app')!;
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { FC, memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter, type RouterHistory } from '@tanstack/react-router';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { QueryFormData, JsonObject } from '@superset-ui/core';
|
||||
import {
|
||||
@@ -60,7 +60,7 @@ interface ExploreActions {
|
||||
saveFaveStar: (sliceId: number, isStarred: boolean) => void;
|
||||
redirectSQLLab: (
|
||||
formData: QueryFormData,
|
||||
history?: RouterHistory | false,
|
||||
history?: ReturnType<typeof useHistory> | false,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -187,14 +187,14 @@ const ExploreChartHeader: FC<ExploreChartHeaderProps> = ({
|
||||
setCurrentReportDeleting(null);
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const history = useHistory();
|
||||
const { redirectSQLLab } = actions;
|
||||
|
||||
const redirectToSQLLab = useCallback(
|
||||
(redirectFormData: QueryFormData, openNewWindow = false) => {
|
||||
redirectSQLLab(redirectFormData, !openNewWindow && router.history);
|
||||
redirectSQLLab(redirectFormData, !openNewWindow && history);
|
||||
},
|
||||
[redirectSQLLab, router],
|
||||
[redirectSQLLab, history],
|
||||
);
|
||||
|
||||
const [menu, isDropdownVisible, setIsDropdownVisible, streamingExportState] =
|
||||
|
||||
@@ -26,10 +26,8 @@ import {
|
||||
VizType,
|
||||
} from '@superset-ui/core';
|
||||
import { QUERY_MODE_REQUISITES } from 'src/explore/constants';
|
||||
import {
|
||||
createMemoryHistory,
|
||||
type RouterHistory,
|
||||
} from '@tanstack/react-router';
|
||||
import { Router, Route } from 'react-router-dom';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
@@ -42,17 +40,6 @@ import reducerIndex from 'spec/helpers/reducerIndex';
|
||||
import * as exploreActions from 'src/explore/actions/exploreActions';
|
||||
import ExploreViewContainer from '.';
|
||||
|
||||
// The component syncs the explore URL through `useRouter().history`;
|
||||
// back it with a spy-able in-memory history per test.
|
||||
let mockRouterHistory: RouterHistory | undefined;
|
||||
|
||||
jest.mock('@tanstack/react-router', () => ({
|
||||
...jest.requireActual('@tanstack/react-router'),
|
||||
useRouter: () => ({
|
||||
history: mockRouterHistory,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.doMock('@superset-ui/core', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
@@ -149,7 +136,7 @@ const renderWithRouter = ({
|
||||
overridePathname?: string;
|
||||
initialState?: object;
|
||||
store?: Store;
|
||||
history?: RouterHistory;
|
||||
history?: ReturnType<typeof createMemoryHistory>;
|
||||
} = {}) => {
|
||||
const path = overridePathname ?? defaultPath;
|
||||
jest.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
@@ -159,15 +146,14 @@ const renderWithRouter = ({
|
||||
const history =
|
||||
existingHistory ??
|
||||
createMemoryHistory({ initialEntries: [`${path}${search}`] });
|
||||
mockRouterHistory = history;
|
||||
const result = render(<ExploreViewContainer />, {
|
||||
useRedux: true,
|
||||
useDnd: true,
|
||||
initialState,
|
||||
store,
|
||||
useRouter: true,
|
||||
initialEntries: [`${path}${search}`],
|
||||
});
|
||||
const result = render(
|
||||
<Router history={history}>
|
||||
<Route path={path}>
|
||||
<ExploreViewContainer />
|
||||
</Route>
|
||||
</Router>,
|
||||
{ useRedux: true, useDnd: true, initialState, store },
|
||||
);
|
||||
return { ...result, history };
|
||||
};
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ import { t } from '@apache-superset/core/translation';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { debounce, isEqual, isObjectLike, omit, pick } from 'lodash';
|
||||
import { Resizable } from 're-resizable';
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Tooltip } from '@superset-ui/core/components';
|
||||
import { usePluginContext } from 'src/components';
|
||||
import { Global } from '@emotion/react';
|
||||
@@ -387,7 +387,7 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
|
||||
getSidebarWidths(LocalStorageKeys.DatasourceWidth),
|
||||
);
|
||||
const tabId = useTabId();
|
||||
const router = useRouter();
|
||||
const history = useHistory();
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -477,7 +477,7 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
|
||||
props.force,
|
||||
title,
|
||||
tabId,
|
||||
router.history,
|
||||
history,
|
||||
);
|
||||
},
|
||||
[
|
||||
@@ -488,7 +488,7 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
|
||||
props.standalone,
|
||||
props.force,
|
||||
tabId,
|
||||
router,
|
||||
history,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
* under the License.
|
||||
*/
|
||||
/* eslint camelcase: 0 */
|
||||
import { ChangeEvent, ComponentProps, FormEvent, Component } from 'react';
|
||||
import { ChangeEvent, FormEvent, Component } from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import { nanoid } from 'nanoid';
|
||||
import rison from 'rison';
|
||||
import { connect } from 'react-redux';
|
||||
import { useRouter, type RouterHistory } from '@tanstack/react-router';
|
||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
import {
|
||||
InfoTooltip,
|
||||
Button,
|
||||
@@ -64,8 +64,7 @@ import { CHART_WIDTH, CHART_HEIGHT } from 'src/dashboard/constants';
|
||||
// Session storage key for recent dashboard
|
||||
const SK_DASHBOARD_ID = 'save_chart_recent_dashboard';
|
||||
|
||||
interface SaveModalProps {
|
||||
history: RouterHistory;
|
||||
interface SaveModalProps extends RouteComponentProps {
|
||||
addDangerToast: (msg: string) => void;
|
||||
actions: Record<string, any>;
|
||||
form_data?: Record<string, any>;
|
||||
@@ -837,18 +836,7 @@ function mapStateToProps({
|
||||
};
|
||||
}
|
||||
|
||||
const ConnectedSaveModal = connect(mapStateToProps)(withTheme(SaveModal));
|
||||
|
||||
// Function wrapper replacing react-router's withRouter HOC: injects the
|
||||
// router history into the class component as an explicit prop.
|
||||
function SaveModalWithRouter(
|
||||
props: Omit<ComponentProps<typeof ConnectedSaveModal>, 'history'>,
|
||||
) {
|
||||
const router = useRouter();
|
||||
return <ConnectedSaveModal {...props} history={router.history} />;
|
||||
}
|
||||
|
||||
export default SaveModalWithRouter;
|
||||
export default withRouter(connect(mapStateToProps)(withTheme(SaveModal)));
|
||||
|
||||
// User for testing purposes need to revisit once we convert this to functional component
|
||||
export { SaveModal as PureSaveModal };
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useLocation } from '@tanstack/react-router';
|
||||
import { Route } from 'react-router-dom';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { DatasourceType, JsonObject, SupersetClient } from '@superset-ui/core';
|
||||
import {
|
||||
@@ -315,19 +315,16 @@ test('Edit dataset should be disabled when user is not admin', async () => {
|
||||
test('Click on View in SQL Lab', async () => {
|
||||
const props = createProps();
|
||||
|
||||
// Renders the current location state once the router navigates to /sqllab,
|
||||
// mimicking the former react-router <Route path="/sqllab" render={...} />.
|
||||
const MockSqlLabRoute = () => {
|
||||
const location = useLocation();
|
||||
if (location.pathname !== '/sqllab') return null;
|
||||
return (
|
||||
<div data-test="mock-sqllab-route">{JSON.stringify(location.state)}</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { queryByTestId, findByTestId, getByTestId } = render(
|
||||
const { queryByTestId, getByTestId } = render(
|
||||
<>
|
||||
<MockSqlLabRoute />
|
||||
<Route
|
||||
path="/sqllab"
|
||||
render={({ location }) => (
|
||||
<div data-test="mock-sqllab-route">
|
||||
{JSON.stringify(location.state)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<DatasourceControl {...props} />
|
||||
</>,
|
||||
{
|
||||
@@ -341,14 +338,14 @@ test('Click on View in SQL Lab', async () => {
|
||||
|
||||
await userEvent.click(screen.getByText('View in SQL Lab'));
|
||||
|
||||
expect(await findByTestId('mock-sqllab-route')).toBeInTheDocument();
|
||||
expect(getByTestId('mock-sqllab-route')).toBeInTheDocument();
|
||||
expect(JSON.parse(`${getByTestId('mock-sqllab-route').textContent}`)).toEqual(
|
||||
expect.objectContaining({
|
||||
{
|
||||
requestedQuery: {
|
||||
datasourceKey: `${mockDatasource.id}__${mockDatasource.type}`,
|
||||
sql: mockDatasource.sql,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ import ViewQueryModalFooter from 'src/explore/components/controls/ViewQueryModal
|
||||
import ViewQuery from 'src/explore/components/controls/ViewQuery';
|
||||
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { safeStringify } from 'src/utils/safeStringify';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Extended Datasource interface with all properties used in this component
|
||||
interface ExtendedDatasource extends Datasource {
|
||||
@@ -415,8 +415,10 @@ class DatasourceControl extends PureComponent<
|
||||
key: VIEW_IN_SQL_LAB,
|
||||
label: (
|
||||
<Link
|
||||
to="/sqllab"
|
||||
state={{ requestedQuery }}
|
||||
to={{
|
||||
pathname: '/sqllab',
|
||||
state: { requestedQuery },
|
||||
}}
|
||||
onClick={preventRouterLinkWhileMetaClicked}
|
||||
>
|
||||
{t('View in SQL Lab')}
|
||||
@@ -470,8 +472,10 @@ class DatasourceControl extends PureComponent<
|
||||
key: VIEW_IN_SQL_LAB,
|
||||
label: (
|
||||
<Link
|
||||
to="/sqllab"
|
||||
state={{ requestedQuery }}
|
||||
to={{
|
||||
pathname: '/sqllab',
|
||||
state: { requestedQuery },
|
||||
}}
|
||||
onClick={preventRouterLinkWhileMetaClicked}
|
||||
>
|
||||
{t('View in SQL Lab')}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user