Compare commits

..

1 Commits

Author SHA1 Message Date
Mehmet Salih Yavuz
4c233d92a3 feat(frontend): migrate react-router v5 to TanStack Router with intent-based preloading
- Replace BrowserRouter/Switch/Route with a TanStack route tree built
  from the existing routes.tsx table; route chunks preload on link
  hover/focus (defaultPreload: 'intent') via fire-and-forget loaders
- Custom raw-string search param codec so rison payloads round-trip
  through the router untouched
- TanstackRouterAdapter replaces ReactRouter5Adapter for use-query-params
- StandaloneRouter (RouterContextProvider-based, synchronous mount) hosts
  the menu entry on Flask pages and component tests
- pushAppHref/replaceAppHref helpers restore v5's basename-prefixing
  semantics for raw href navigation
- useUnsavedChangesPrompt now uses useBlocker
- Migrate all 92 importing files; drop react-router-dom dependency
2026-06-10 23:25:23 +03:00
224 changed files with 3098 additions and 24268 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9300,9 +9300,9 @@ jiti@^1.20.0:
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
joi@^17.9.2:
version "17.13.4"
resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.4.tgz#ad6153d97ce558eb3a3b593e0d43eab51df1c474"
integrity sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==
version "17.13.3"
resolved "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz"
integrity sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==
dependencies:
"@hapi/hoek" "^9.3.0"
"@hapi/topo" "^5.1.0"

View File

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

View File

@@ -50,7 +50,7 @@ cattrs==25.1.1
# via requests-cache
celery==5.5.2
# via apache-superset (pyproject.toml)
certifi==2026.5.20
certifi==2025.6.15
# via
# requests
# selenium
@@ -194,7 +194,7 @@ jinja2==3.1.6
# via
# flask
# flask-babel
jsonpath-ng==1.8.0
jsonpath-ng==1.7.0
# via apache-superset (pyproject.toml)
jsonschema==4.23.0
# via
@@ -286,6 +286,8 @@ 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
@@ -378,7 +380,7 @@ rpds-py==0.25.0
# referencing
rsa==4.9.1
# via google-auth
selenium==4.44.0
selenium==4.32.0
# via apache-superset (pyproject.toml)
setuptools==80.9.0
# via -r requirements/base.in
@@ -421,7 +423,7 @@ sshtunnel==0.4.0
# via apache-superset (pyproject.toml)
tabulate==0.10.0
# via apache-superset (pyproject.toml)
trio==0.33.0
trio==0.30.0
# via
# selenium
# trio-websocket
@@ -478,7 +480,7 @@ wrapt==1.17.2
# via deprecated
wsproto==1.2.0
# via trio-websocket
wtforms==3.2.2
wtforms==3.2.1
# via
# apache-superset (pyproject.toml)
# flask-appbuilder

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -154,6 +154,7 @@
"@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",
@@ -178,7 +179,7 @@
"echarts": "^5.6.0",
"fast-glob": "^3.3.2",
"fs-extra": "^11.3.5",
"fuse.js": "^7.4.1",
"fuse.js": "^7.3.0",
"geolib": "^3.3.14",
"geostyler": "^18.6.0",
"geostyler-data": "^1.1.0",
@@ -218,7 +219,6 @@
"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.13",
"@formatjs/intl-durationformat": "^0.10.3",
"@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.2",
"@storybook/addon-links": "10.4.2",
"@storybook/react-webpack5": "10.4.2",
"@storybook/addon-docs": "10.4.1",
"@storybook/addon-links": "10.4.1",
"@storybook/react-webpack5": "10.4.1",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.40",
@@ -289,7 +289,6 @@
"@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",
@@ -325,7 +324,7 @@
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.4",
"eslint-plugin-storybook": "10.4.2",
"eslint-plugin-storybook": "10.4.1",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
@@ -355,7 +354,7 @@
"source-map": "^0.7.6",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.4.2",
"storybook": "10.4.1",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.1",
@@ -382,8 +381,8 @@
"regenerator-runtime": "^0.14.1"
},
"engines": {
"node": "^24.16.0",
"npm": "^11.13.0"
"node": "^22.22.0",
"npm": "^10.8.1"
},
"overrides": {
"uuid": "$uuid",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,7 +43,7 @@
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.21",
"dompurify": "^3.4.8",
"dompurify": "^3.4.7",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
"jed": "^1.1.1",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -197,62 +197,6 @@ function checkI18nTemplates(ast, filepath) {
});
}
/**
* Check for eager t()/tn() calls in `label` / `description` properties of
* config objects evaluated at module load (e.g., controlPanel files). The
* translation is captured at module-evaluation time, before i18n has loaded,
* and never updates when the user switches language. The fix is to wrap the
* call in an arrow function: `label: () => t('Foo')`.
*
* Limited to controlPanel files because that's where this pattern is
* problematic at scale; t() inside JSX or component bodies is evaluated at
* render time and works fine.
*/
const EAGER_T_WATCHED_PROPS = new Set(['label', 'description']);
function checkEagerTranslationsInConfig(ast, filepath) {
if (!/controlPanel\.(ts|tsx|js|jsx)$/.test(filepath)) return;
traverse(ast, {
ObjectProperty(path) {
const { node } = path;
if (node.computed || node.shorthand) return;
const keyName =
node.key.type === 'Identifier'
? node.key.name
: node.key.type === 'StringLiteral'
? node.key.value
: null;
if (!keyName || !EAGER_T_WATCHED_PROPS.has(keyName)) return;
const { value } = node;
if (
value.type !== 'CallExpression' ||
value.callee.type !== 'Identifier' ||
(value.callee.name !== 't' && value.callee.name !== 'tn')
) {
return;
}
if (hasEslintDisable(path, 'i18n-strings/no-eager-t-in-config')) return;
// Warn (not error) because there are many pre-existing violations.
// The ESLint plugin provides an autofix so authors can sweep files
// as they touch them. Promote to error once the codebase is clean.
// eslint-disable-next-line no-console
console.warn(
`${YELLOW}${RESET} ${filepath}:${node.loc?.start.line ?? '?'}: ` +
`Eager \`${keyName}: ${value.callee.name}(...)\` is evaluated at ` +
`module load, before i18n is initialized. Wrap in an arrow ` +
`function: \`${keyName}: () => ${value.callee.name}(...)\`. ` +
`Run \`eslint --fix\` to autofix.`,
);
warningCount += 1;
},
});
}
/**
* Props that should contain translated strings
*/
@@ -621,7 +565,6 @@ function processFile(filepath) {
checkNoLiteralColors(ast, filepath);
checkNoFaIcons(ast, filepath);
checkI18nTemplates(ast, filepath);
checkEagerTranslationsInConfig(ast, filepath);
checkUntranslatedStrings(ast, filepath);
} catch (error) {
// eslint-disable-next-line no-console

View File

@@ -19,7 +19,6 @@
import {
DataMaskStateWithId,
ExtraFormData,
Filter,
NativeFiltersState,
NativeFilterType,
} from '@superset-ui/core';
@@ -459,25 +458,6 @@ 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,

View File

@@ -19,18 +19,18 @@
import { ThemeProvider } from '@apache-superset/core/theme';
import querystring from 'query-string';
import { BrowserRouter as Router } from 'react-router-dom';
import { StandaloneRouter } from 'src/router/StandaloneRouter';
import { TanstackRouterAdapter } from 'src/router/queryParamAdapter';
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}>
<Router>
<StandaloneRouter>
<QueryParamProvider
adapter={ReactRouter5Adapter}
adapter={TanstackRouterAdapter}
options={{
searchStringToObject: querystring.parse,
objectToSearchString: (object: Record<string, any>) =>
@@ -39,7 +39,7 @@ export function ProviderWrapper(props: any) {
>
{children}
</QueryParamProvider>
</Router>
</StandaloneRouter>
</ThemeProvider>
);
}

View File

@@ -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 { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
import { StandaloneRouter } from 'src/router/StandaloneRouter';
import { TanstackRouterAdapter } from 'src/router/queryParamAdapter';
import { configureStore, Store } from '@reduxjs/toolkit';
import { api } from 'src/hooks/apiResources/queryApi';
import userEvent from '@testing-library/user-event';
@@ -55,6 +55,8 @@ 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 });
@@ -84,6 +86,7 @@ export function createWrapper(options?: Options) {
initialState,
reducers,
store,
initialEntries,
} = options || {};
return ({ children }: { children?: ReactNode }) => {
@@ -116,14 +119,18 @@ export function createWrapper(options?: Options) {
if (useQueryParams) {
result = (
<QueryParamProvider adapter={ReactRouter5Adapter}>
<QueryParamProvider adapter={TanstackRouterAdapter}>
{result}
</QueryParamProvider>
);
}
if (useRouter) {
result = <BrowserRouter>{result}</BrowserRouter>;
if (useRouter || useQueryParams || initialEntries) {
result = (
<StandaloneRouter initialEntries={initialEntries}>
{result}
</StandaloneRouter>
);
}
return result;

View File

@@ -18,7 +18,7 @@
*/
import { PureComponent } from 'react';
import { connect } from 'react-redux';
import { Redirect } from 'react-router-dom';
import { Navigate } from '@tanstack/react-router';
import Mousetrap from 'mousetrap';
import { t } from '@apache-superset/core/translation';
import { css, styled } from '@apache-superset/core/theme';
@@ -208,13 +208,7 @@ class App extends PureComponent<AppProps, AppState> {
render() {
const { queries, queriesLastUpdate } = this.props;
if (this.state.hash && this.state.hash === '#search') {
return (
<Redirect
to={{
pathname: '/sqllab/history/',
}}
/>
);
return <Navigate to="/sqllab/history/" replace />;
}
return (
<SqlLabStyles data-test="SqlLabApp" className="App SqlLab">

View File

@@ -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 { useViews } from 'src/core';
import { views } 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 = useViews(ViewLocations.sqllab.rightSidebar) || [];
const viewItems = views.getViews(ViewLocations.sqllab.rightSidebar) || [];
return (
<StyledContainer>

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { MemoryRouter } from 'react-router-dom';
import { StandaloneRouter } from 'src/router/StandaloneRouter';
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(
<MemoryRouter initialEntries={[url]}>
<StandaloneRouter initialEntries={[url]}>
<LocationProvider>
<PopEditorTab />
</LocationProvider>
</MemoryRouter>,
</StandaloneRouter>,
{
useRedux: true,
initialState: overridesInitialState || initialState,

View File

@@ -30,7 +30,8 @@ import {
import AutoSizer from 'react-virtualized-auto-sizer';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { useHistory } from 'react-router-dom';
import { useRouter } from '@tanstack/react-router';
import { pushAppHref } from 'src/router/navigation';
import { pick } from 'lodash';
import {
Button,
@@ -232,7 +233,7 @@ const ResultSet = ({
canExportDataSqlLab: canExportData,
canCopyClipboardSqlLab: canCopyClipboard,
} = usePermissions();
const history = useHistory();
const router = useRouter();
const dispatch = useAppDispatch();
const logAction = useLogAction({ queryId, sqlEditorId: query.sqlEditorId });
const { showConfirm, ConfirmModal } = useConfirmModal();
@@ -314,7 +315,7 @@ const ResultSet = ({
if (openInNewWindow) {
window.open(url, '_blank', 'noreferrer');
} else {
history.push(url);
pushAppHref(router, url);
}
} else {
addDangerToast(t('Unable to create chart without a query id.'));

View File

@@ -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 { useViews } from 'src/core';
import { views } 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 = useViews(ViewLocations.sqllab.panels) || [];
const viewItems = views.getViews(ViewLocations.sqllab.panels) || [];
const { offline, tables } = useSelector(
({ sqlLab: { offline, tables } }: SqlLabRootState) => ({
offline,

View File

@@ -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 { useViews } from 'src/core';
import { views } 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 = useViews(ViewLocations.sqllab.statusBar) || [];
const statusBarViews = views.getViews(ViewLocations.sqllab.statusBar) || [];
return (
<>

View File

@@ -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 'react-router-dom';
import { Link } from '@tanstack/react-router';
import {
Button,
Modal,
@@ -78,7 +78,7 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
const dispatch = useDispatch();
const { addDangerToast } = useToasts();
const theme = useTheme();
const [url, setUrl] = useState('');
const [formDataKey, setFormDataKey] = useState('');
const dashboardPageId = useContext(DashboardPageIdContext);
const onEditChartClick = useCallback(() => {
dispatch(
@@ -97,9 +97,7 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
if (isEmbedded()) return;
postFormData(Number(datasource_id), datasource_type, formData, 0)
.then(key => {
setUrl(
`/explore/?form_data_key=${key}&dashboard_page_id=${dashboardPageId}`,
);
setFormDataKey(key);
})
.catch(() => {
addDangerToast(t('Failed to generate chart edit URL'));
@@ -111,7 +109,7 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
datasource_type,
formData,
]);
const isEditDisabled = !url || !canExplore;
const isEditDisabled = !formDataKey || !canExplore;
return (
<>
@@ -133,7 +131,11 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
text-decoration: none;
}
`}
to={url}
to="/explore/"
search={{
form_data_key: formDataKey,
dashboard_page_id: dashboardPageId,
}}
>
{t('Edit chart')}
</Link>

View File

@@ -25,10 +25,12 @@ import DrillDetailModal from './DrillDetailModal';
jest.mock('./DrillDetailPane', () => () => null);
const mockHistoryPush = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: () => ({
push: mockHistoryPush,
jest.mock('@tanstack/react-router', () => ({
...jest.requireActual('@tanstack/react-router'),
useRouter: () => ({
history: {
push: mockHistoryPush,
},
}),
}));

View File

@@ -18,7 +18,8 @@
*/
import { useCallback, useContext, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import { useRouter } from '@tanstack/react-router';
import { pushAppHref } from 'src/router/navigation';
import { t } from '@apache-superset/core/translation';
import {
BinaryQueryObjectFilterClause,
@@ -98,7 +99,7 @@ export default function DrillDetailModal({
dataset,
}: DrillDetailModalProps) {
const theme = useTheme();
const history = useHistory();
const router = useRouter();
const dashboardPageId = useContext(DashboardPageIdContext);
const { slice_name: chartName } = useSelector(
(state: { sliceEntities: { slices: Record<number, Slice> } }) =>
@@ -114,8 +115,8 @@ export default function DrillDetailModal({
);
const exploreChart = useCallback(() => {
history.push(exploreUrl);
}, [exploreUrl, history]);
pushAppHref(router, exploreUrl);
}, [exploreUrl, router]);
return (
<Modal

View File

@@ -17,32 +17,49 @@
* under the License.
*/
import { sanitizeUrl } from '@braintree/sanitize-url';
import { PropsWithoutRef, RefAttributes } from 'react';
import { Link, LinkProps } from 'react-router-dom';
import { AnchorHTMLAttributes } from 'react';
import { Link } from '@tanstack/react-router';
import { parseSearch } from 'src/router/searchParams';
import { isUrlExternal, parseUrl } from 'src/utils/urlUtils';
export const GenericLink = <S,>({
to,
component,
export type GenericLinkProps = Omit<
AnchorHTMLAttributes<HTMLAnchorElement>,
'href'
> & {
to: string;
replace?: boolean;
};
export const GenericLink = ({
to: rawTo,
replace,
innerRef,
children,
...rest
}: PropsWithoutRef<LinkProps<S>> & RefAttributes<HTMLAnchorElement>) => {
if (typeof to === 'string' && isUrlExternal(to)) {
}: GenericLinkProps) => {
// Callers may pass undefined at runtime (e.g. backend rows without a URL).
const to = typeof rawTo === 'string' ? rawTo : '';
if (to && 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={to}
component={component}
to={pathname}
search={searchStr ? parseSearch(searchStr) : undefined}
hash={hash}
replace={replace}
innerRef={innerRef}
{...rest}
>
{children}

View File

@@ -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 'react-router-dom';
import { Link } from '@tanstack/react-router';
import CrossLinksTooltip from './CrossLinksTooltip';
export type CrossLinkProps = {

View File

@@ -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 'react-router-dom';
import { Link } from '@tanstack/react-router';
import { Tooltip } from '@superset-ui/core/components';
export type CrossLinksTooltipProps = {

View File

@@ -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 { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
import { MemoryRouter } from 'react-router-dom';
import { StandaloneRouter } from 'src/router/StandaloneRouter';
import { TanstackRouterAdapter } from 'src/router/queryParamAdapter';
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(
<MemoryRouter>
<QueryParamProvider adapter={ReactRouter5Adapter}>
<StandaloneRouter initialEntries={['/']}>
<QueryParamProvider adapter={TanstackRouterAdapter}>
<ListView {...props} />
</QueryParamProvider>
</MemoryRouter>,
</StandaloneRouter>,
{ store: mockStore() },
);
};

View File

@@ -17,12 +17,11 @@
* 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 } from 'src/core';
import { commands, menus } from 'src/core';
export interface PanelToolbarProps {
viewId: string;
@@ -36,7 +35,7 @@ const PanelToolbar = ({
defaultSecondaryActions,
}: PanelToolbarProps) => {
const theme = useTheme();
const menu = useMenu(viewId);
const menu = menus.getMenu(viewId);
const primaryItems = menu?.primary || [];
const secondaryItems = menu?.secondary || [];

View File

@@ -18,7 +18,7 @@
*/
import { styled } from '@apache-superset/core/theme';
import { Link } from 'react-router-dom';
import { Link } from '@tanstack/react-router';
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,7 +82,8 @@ const SupersetTag = ({
{' '}
{id ? (
<Link
to={`/superset/all_entities/?id=${id}`}
to="/superset/all_entities/"
search={{ id: String(id) }}
target="_blank"
rel="noreferrer"
>

View File

@@ -39,7 +39,8 @@ jest.mock('./EditorProviders', () => ({
getInstance: () => ({
getProvider: jest.fn().mockReturnValue(undefined),
hasProvider: jest.fn().mockReturnValue(false),
subscribe: jest.fn().mockReturnValue(() => {}),
onDidRegister: jest.fn().mockReturnValue({ dispose: jest.fn() }),
onDidUnregister: jest.fn().mockReturnValue({ dispose: jest.fn() }),
}),
},
}));

View File

@@ -26,12 +26,13 @@
* back to the default Ace editor.
*/
import { useSyncExternalStore, forwardRef } from 'react';
import { useState, useEffect, 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;
@@ -41,6 +42,49 @@ 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.
*
@@ -62,12 +106,7 @@ export type EditorHostProps = EditorProps;
const EditorHost = forwardRef<EditorHandle, EditorHostProps>((props, ref) => {
const { language } = props;
const theme = useTheme();
const manager = EditorProviders.getInstance();
const provider = useSyncExternalStore(
manager.subscribe,
() => manager.getProvider(language),
() => undefined,
);
const provider = useEditorProvider(language);
// Merge theme into props
const propsWithTheme = { ...props, theme };

View File

@@ -93,17 +93,6 @@ 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
@@ -156,7 +145,6 @@ class EditorProviders {
// Fire registration event
this.registerEmitter.fire({ editor });
this.syncListeners.forEach(l => l());
// Return disposable for cleanup
return new Disposable(() => {
@@ -188,7 +176,6 @@ class EditorProviders {
// Fire unregistration event
this.unregisterEmitter.fire({ editor });
this.syncListeners.forEach(l => l());
}
/**
@@ -247,7 +234,6 @@ class EditorProviders {
public reset(): void {
this.providers.clear();
this.languageToProvider.clear();
this.syncListeners.clear();
}
}

View File

@@ -24,7 +24,6 @@
* 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';
@@ -110,23 +109,6 @@ 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.
*/

View File

@@ -24,14 +24,11 @@
* 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;
@@ -41,27 +38,6 @@ 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,
@@ -69,13 +45,11 @@ 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 });
});
};
@@ -103,34 +77,7 @@ 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,
};

View File

@@ -24,15 +24,13 @@
* Extensions register views as side effects at import time.
*/
import React, { ReactElement, useSyncExternalStore } from 'react';
import React, { ReactElement } 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,
@@ -41,27 +39,6 @@ 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,
@@ -74,12 +51,10 @@ 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 });
});
};
@@ -102,35 +77,7 @@ 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,
};

View File

@@ -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 { History } from 'history';
import type { RouterHistory } from '@tanstack/react-router';
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: History;
history: RouterHistory;
dashboard: HydrateDashboardData;
charts: HydrateChartData[];
dataMask: DataMaskStateWithId;
@@ -278,9 +278,10 @@ export const hydrateDashboard =
// Removes the focused_chart parameter from the URL
const params = new URLSearchParams(window.location.search);
params.delete(URL_PARAMS.dashboardFocusedChart.name);
history.replace({
search: params.toString(),
});
const paramString = params.toString();
history.replace(
`${history.location.pathname}${paramString ? `?${paramString}` : ''}`,
);
}
// find direct link component and path from root

View File

@@ -32,14 +32,16 @@ import { UPDATE_COMPONENTS } from '../../actions/dashboardLayout';
import { AutoRefreshStatus } from '../../types/autoRefresh';
const mockHistoryReplace = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: () => ({
replace: mockHistoryReplace,
jest.mock('@tanstack/react-router', () => ({
...jest.requireActual('@tanstack/react-router'),
useRouter: () => ({
history: {
replace: mockHistoryReplace,
},
}),
useLocation: jest.fn(() => ({
pathname: '/dashboard',
search: '?standalone=1',
searchStr: 'standalone=1',
hash: '',
state: undefined,
})),
@@ -237,10 +239,10 @@ beforeAll(() => {
beforeEach(() => {
jest.clearAllMocks();
const { useLocation } = jest.requireMock('react-router-dom');
const { useLocation } = jest.requireMock('@tanstack/react-router');
useLocation.mockReturnValue({
pathname: '/dashboard',
search: '?standalone=1',
searchStr: 'standalone=1',
hash: '',
state: undefined,
});
@@ -1051,11 +1053,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('react-router-dom');
const { useLocation } = jest.requireMock('@tanstack/react-router');
// Simulate React Router with basename=/pcs: useLocation returns path relative to basename
useLocation.mockReturnValue({
pathname: '/dashboard',
search: '?standalone=1',
searchStr: 'standalone=1',
hash: '',
state: undefined,
});
@@ -1078,10 +1080,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('react-router-dom');
const { useLocation } = jest.requireMock('@tanstack/react-router');
useLocation.mockReturnValue({
pathname: '/dashboard',
search: '',
searchStr: '',
hash: '',
state: undefined,
});
@@ -1100,11 +1102,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('react-router-dom');
const { useLocation } = jest.requireMock('@tanstack/react-router');
// Router returns path without the subdirectory prefix
useLocation.mockReturnValue({
pathname: '/dashboard',
search: '',
searchStr: '',
hash: '',
state: undefined,
});

View File

@@ -19,7 +19,8 @@
import type { Dispatch, ReactElement, SetStateAction } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { useLocation, useRouter } from '@tanstack/react-router';
import { replaceAppHref } from 'src/router/navigation';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { t } from '@apache-superset/core/translation';
import { isEmpty } from 'lodash';
@@ -74,7 +75,7 @@ export const useHeaderActionsMenu = ({
] => {
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const { canExportImage } = usePermissions();
const history = useHistory();
const router = useRouter();
const location = useLocation();
const directPathToChild = useSelector(
(state: RootState) => state.dashboardState.directPathToChild,
@@ -102,7 +103,7 @@ export const useHeaderActionsMenu = ({
case MenuKeys.ToggleFullscreen: {
const isCurrentlyStandalone =
Number(getUrlParam(URL_PARAMS.standalone)) === 1;
// Use location.pathname from React Router (relative to basename) rather than
// Use location.pathname from the router (relative to basepath) rather than
// window.location.pathname to avoid duplicating the subdirectory prefix when
// history.replace prepends it again.
const url = getDashboardUrl({
@@ -111,7 +112,7 @@ export const useHeaderActionsMenu = ({
hash: window.location.hash,
standalone: isCurrentlyStandalone ? null : 1,
});
history.replace(url);
replaceAppHref(router, url);
break;
}
case MenuKeys.ManageEmbedded:
@@ -128,7 +129,7 @@ export const useHeaderActionsMenu = ({
showPropertiesModal,
showRefreshModal,
manageEmbedded,
history,
router,
location,
],
);

View File

@@ -16,10 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { createMemoryHistory } from '@tanstack/react-router';
import { StandaloneRouter } from 'src/router/StandaloneRouter';
import { getExtensionsRegistry, VizType } from '@superset-ui/core';
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
import { useUiConfig } from 'src/components/UiConfigContext';
import SliceHeader from '.';
@@ -283,12 +288,12 @@ test('Should render click to edit prompt and run onExploreChart on click', async
initialEntries: ['/superset/dashboard/1/'],
});
render(
<Router history={history}>
<StandaloneRouter history={history}>
<SliceHeader {...props} />
</Router>,
</StandaloneRouter>,
{ useRedux: true, initialState },
);
userEvent.hover(screen.getByText('Vaccine Candidates per Phase'));
userEvent.hover(await screen.findByText('Vaccine Candidates per Phase'));
expect(
await screen.findByText('Click to edit Vaccine Candidates per Phase.'),
).toBeInTheDocument();
@@ -297,7 +302,8 @@ test('Should render click to edit prompt and run onExploreChart on click', async
).toBeInTheDocument();
userEvent.click(screen.getByText('Vaccine Candidates per Phase'));
expect(history.location.pathname).toMatch('/explore');
// TanStack router commits navigation asynchronously.
await waitFor(() => expect(history.location.pathname).toMatch('/explore'));
});
test('Display cmd button in tooltip if running on MacOS', async () => {
@@ -317,18 +323,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', () => {
test('Should not render click to edit prompt and run onExploreChart on click if supersetCanExplore=false', async () => {
const props = createProps({ supersetCanExplore: false });
const history = createMemoryHistory({
initialEntries: ['/superset/dashboard/1/'],
});
render(
<Router history={history}>
<StandaloneRouter history={history}>
<SliceHeader {...props} />
</Router>,
</StandaloneRouter>,
{ useRedux: true, initialState },
);
userEvent.hover(screen.getByText('Vaccine Candidates per Phase'));
userEvent.hover(await screen.findByText('Vaccine Candidates per Phase'));
expect(
screen.queryByText(
'Click to edit Vaccine Candidates per Phase in a new tab',
@@ -339,18 +345,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', () => {
test('Should not render click to edit prompt and run onExploreChart on click if in edit mode', async () => {
const props = createProps({ editMode: true });
const history = createMemoryHistory({
initialEntries: ['/superset/dashboard/1/'],
});
render(
<Router history={history}>
<StandaloneRouter history={history}>
<SliceHeader {...props} />
</Router>,
</StandaloneRouter>,
{ useRedux: true, initialState },
);
userEvent.hover(screen.getByText('Vaccine Candidates per Phase'));
userEvent.hover(await screen.findByText('Vaccine Candidates per Phase'));
expect(
screen.queryByText(
'Click to edit Vaccine Candidates per Phase in a new tab',

View File

@@ -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 'react-router-dom';
import { Link } from '@tanstack/react-router';
const extensionsRegistry = getExtensionsRegistry();
@@ -245,7 +245,11 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
const renderExploreLink = (title: string) => (
<Link
to={exploreUrl}
to="/explore/"
search={{
dashboard_page_id: dashboardPageId,
slice_id: String(slice.slice_id),
}}
css={(theme: SupersetTheme) => css`
color: ${theme.colorText};
text-decoration: none;

View File

@@ -17,7 +17,8 @@
* under the License.
*/
import { ReactChild, RefObject, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { useRouter } from '@tanstack/react-router';
import { pushAppHref } from 'src/router/navigation';
import { t } from '@apache-superset/core/translation';
import { css, useTheme } from '@apache-superset/core/theme';
import { Button, ModalTrigger } from '@superset-ui/core/components';
@@ -37,8 +38,9 @@ export const ViewResultsModalTrigger = ({
modalBody: ReactChild;
modalRef?: RefObject<any>;
}) => {
const history = useHistory();
const exploreChart = () => history.push(exploreUrl);
const router = useRouter();
// exploreUrl carries a query string; raw history push preserves it.
const exploreChart = () => pushAppHref(router, exploreUrl);
const theme = useTheme();
const handleCloseModal = useCallback(() => {
modalRef?.current?.close();

View File

@@ -26,7 +26,8 @@ import {
RefObject,
} from 'react';
import { RouteComponentProps, useHistory } from 'react-router-dom';
import { useRouter } from '@tanstack/react-router';
import { pushAppHref } from 'src/router/navigation';
import { extendedDayjs } from '@superset-ui/core/utils/dates';
import { t } from '@apache-superset/core/translation';
import {
@@ -144,8 +145,7 @@ export interface SliceHeaderControlsProps {
crossFiltersEnabled?: boolean;
}
type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps &
RouteComponentProps;
type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps;
const dropdownIconsStyles = css`
&&.anticon > .anticon:first-of-type {
@@ -169,7 +169,7 @@ const SliceHeaderControls = (
const [openScopingModal, scopingModal] = useCrossFiltersScopingModal(
props.slice.slice_id,
);
const history = useHistory();
const router = useRouter();
const queryMenuRef: RefObject<any> = useRef(null);
const resultsMenuRef: RefObject<any> = useRef(null);
@@ -265,7 +265,8 @@ const SliceHeaderControls = (
domEvent.preventDefault();
window.open(props.exploreUrl, '_blank');
} else {
history.push(props.exploreUrl);
// exploreUrl carries a query string; raw history push preserves it.
pushAppHref(router, props.exploreUrl);
}
break;
case MenuKeys.ExportCsv:

View File

@@ -960,92 +960,3 @@ 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();
});

View File

@@ -1,305 +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 { 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));
});
});

View File

@@ -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 { useHistory, useLocation } from 'react-router-dom';
import { useLocation, useRouter } from '@tanstack/react-router';
import { removeDataMask, updateDataMask } from 'src/dataMask/actions';
import {
getRisonFilterParam,
@@ -121,8 +121,8 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
state => state.dataMask,
);
const dispatch = useDispatch();
const history = useHistory();
const location = useLocation();
const router = useRouter();
const searchStr = useLocation({ select: location => location.searchStr });
const chartIds = useChartIds();
const chartLayoutItems = useChartLayoutItems();
const verboseMaps = useChartsVerboseMaps();
@@ -146,7 +146,7 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
// programmatic history.replace).
useEffect(() => {
setActiveUrlFilters(getUrlFilterIndicators());
}, [location.search]);
}, [searchStr]);
const handleRemoveUrlFilter = useCallback(
(filterToRemove: UrlFilterIndicator) => {
@@ -158,7 +158,7 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
const remaining = currentFilters.filter(
f => getUrlFilterIdentity(f) !== removeId,
);
updateUrlWithUnmatchedFilters(remaining, history);
updateUrlWithUnmatchedFilters(remaining, router.history);
setActiveUrlFilters(prev =>
prev.filter(f => getUrlFilterIdentity(f.filter) !== removeId),
);
@@ -175,7 +175,7 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
);
}
},
[dispatch, history],
[dispatch, router],
);
const urlFiltersComponent = useMemo(() => {

View File

@@ -16,26 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
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 { NativeFilterType } from '@superset-ui/core';
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,
@@ -49,7 +32,7 @@ const defaultProps = {
onPendingCustomizationDataMaskChange: jest.fn(),
};
const renderWrapper = (overrideProps?: Partial<HorizontalBarProps>) =>
const renderWrapper = (overrideProps?: Record<string, any>) =>
waitFor(() =>
render(<HorizontalBar {...defaultProps} {...overrideProps} />, {
useRedux: true,
@@ -77,13 +60,11 @@ 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(
@@ -111,133 +92,3 @@ 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();
});

View File

@@ -24,9 +24,15 @@
* - 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 'history';
import { Router } from 'react-router-dom';
import { act, render, screen, userEvent } from 'spec/helpers/testing-library';
import { createMemoryHistory } from '@tanstack/react-router';
import { StandaloneRouter } from 'src/router/StandaloneRouter';
import {
act,
render,
screen,
userEvent,
waitFor,
} 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';
@@ -39,7 +45,7 @@ jest.mock('react-redux', () => ({
const seedUrl = (search: string) => {
// jsdom doesn't navigate, so set both window.location (read by
// getRisonFilterParam) and react-router's in-memory history.
// getRisonFilterParam) and the router's in-memory history.
window.history.replaceState({}, '', `/superset/dashboard/1/${search}`);
};
@@ -49,9 +55,9 @@ const renderAt = (search: string) => {
initialEntries: [`/superset/dashboard/1/${search}`],
});
const utils = render(
<Router history={history}>
<StandaloneRouter history={history}>
<UrlFiltersVertical />
</Router>,
</StandaloneRouter>,
{ useRedux: true },
);
return { ...utils, history };
@@ -120,7 +126,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)', () => {
test('chip list re-renders when the URL changes (popstate/programmatic nav)', async () => {
const { history } = renderAt('?f=(region:EMEA)');
expect(screen.getByText('region')).toBeInTheDocument();
@@ -133,7 +139,8 @@ test('chip list re-renders when the URL changes (popstate/programmatic nav)', ()
history.replace('/superset/dashboard/1/?f=(priority:high)');
});
expect(screen.getByText('priority')).toBeInTheDocument();
// The router commits location updates asynchronously.
await waitFor(() => expect(screen.getByText('priority')).toBeInTheDocument());
expect(screen.getByText('high')).toBeInTheDocument();
expect(screen.queryByText('region')).not.toBeInTheDocument();
});

View File

@@ -19,7 +19,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { useLocation, useRouter } from '@tanstack/react-router';
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 history = useHistory();
const location = useLocation();
const router = useRouter();
const searchStr = useLocation({ select: location => location.searchStr });
const [urlFilters, setUrlFilters] = useState<UrlFilterIndicator[]>(() =>
getUrlFilterIndicators(),
);
@@ -48,7 +48,7 @@ const UrlFiltersVertical = () => {
// programmatic history.replace).
useEffect(() => {
setUrlFilters(getUrlFilterIndicators());
}, [location.search]);
}, [searchStr]);
const handleRemoveFilter = useCallback(
(filterToRemove: UrlFilterIndicator) => {
@@ -61,7 +61,7 @@ const UrlFiltersVertical = () => {
f => getUrlFilterIdentity(f) !== removeId,
);
updateUrlWithUnmatchedFilters(remaining, history);
updateUrlWithUnmatchedFilters(remaining, router.history);
setUrlFilters(prev =>
prev.filter(f => getUrlFilterIdentity(f.filter) !== removeId),
);
@@ -78,7 +78,7 @@ const UrlFiltersVertical = () => {
);
}
},
[dispatch, history],
[dispatch, router],
);
if (!urlFilters.length) {

View File

@@ -43,7 +43,7 @@ import {
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { Constants } from '@superset-ui/core/components';
import { useHistory } from 'react-router-dom';
import { useRouter, type RouterHistory } from '@tanstack/react-router';
import { updateDataMask, removeDataMask } from 'src/dataMask/actions';
import {
saveChartCustomization,
@@ -55,7 +55,6 @@ 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';
@@ -96,7 +95,7 @@ const EMPTY_DATA_MASK_RECORD: Record<string, DataMask> = {};
const publishDataMask = debounce(
async (
history,
history: RouterHistory,
dashboardId,
updateKey,
dataMaskSelected: DataMaskStateWithId,
@@ -145,15 +144,10 @@ 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 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);
}
// 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;
// Manually reconstruct the search string to preserve Rison filter encoding
let searchString = newParams.toString();
if (rawRisonFilterValue) {
@@ -161,10 +155,9 @@ const publishDataMask = debounce(
searchString = `${searchString}${separator}f=${rawRisonFilterValue}`;
}
history.replace({
pathname: replacementPathname,
search: searchString,
});
history.replace(
`${replacementPathname}${searchString ? `?${searchString}` : ''}`,
);
}
},
Constants.SLOW_DEBOUNCE,
@@ -175,7 +168,7 @@ const FilterBar: FC<FiltersBarProps> = ({
verticalConfig,
hidden = false,
}) => {
const history = useHistory();
const router = useRouter();
const dataMaskApplied: DataMaskStateWithId = useAllAppliedDataMask();
const [dataMaskSelected, setDataMaskSelected] =
@@ -406,10 +399,16 @@ const FilterBar: FC<FiltersBarProps> = ({
useEffect(() => {
// embedded users can't persist filter combinations
if (user?.userId) {
publishDataMask(history, dashboardId, updateKey, dataMaskApplied, tabId);
publishDataMask(
router.history,
dashboardId,
updateKey,
dataMaskApplied,
tabId,
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, dataMaskAppliedText, history, updateKey, tabId]);
}, [dashboardId, dataMaskAppliedText, router, updateKey, tabId]);
const pendingChartCustomizations = useSelector<
RootState,

View File

@@ -18,7 +18,7 @@
*/
import { createContext, lazy, FC, useEffect, useMemo, useRef } from 'react';
import { Global } from '@emotion/react';
import { useHistory } from 'react-router-dom';
import { useRouter } from '@tanstack/react-router';
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 history = useHistory();
const router = useRouter();
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 react-router's history keeps `history.location.search` in
// through the router's history keeps its 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,
history,
router.history,
);
}
if (injectionResult.unmatchedFilters.length > 0) {
@@ -289,7 +289,7 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
}
dispatch(
hydrateDashboard({
history,
history: router.history,
dashboard: dashboard!,
charts: charts!,
activeTabs: activeTabs ?? null,

View File

@@ -353,10 +353,10 @@ test('updateUrlWithUnmatchedFilters goes through history when supplied', () => {
);
expect(replace).toHaveBeenCalledTimes(1);
const call = replace.mock.calls[0][0];
expect(call.pathname).toBe('/superset/dashboard/1/');
expect(call.search).toContain('f=');
expect(call.search).toContain('region');
const href = replace.mock.calls[0][0];
expect(href).toMatch(/^\/superset\/dashboard\/1\/\?/);
expect(href).toContain('f=');
expect(href).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].search).toBe('');
expect(replace.mock.calls[0][0]).toBe('/superset/dashboard/1/');
window.history.replaceState({}, '', originalLocation);
});
@@ -382,16 +382,20 @@ 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 react-router's history with a fake whose `.location`
// Stand in for the router's history with a fake whose `.location`
// updates synchronously when .replace is called — same contract as
// react-router-dom's history.replace.
// the router history's replace.
const fakeHistory = {
location: {
pathname: '/superset/dashboard/1/',
search: '?f=(country:USA)',
},
replace(next: { pathname: string; search: string }) {
this.location = next;
replace(href: string) {
const [pathname, search = ''] = href.split('?');
this.location = {
pathname,
search: search ? `?${search}` : '',
};
},
};
const originalLocation = window.location.href;

View File

@@ -318,15 +318,15 @@ export function risonFiltersToString(filters: RisonFilter[]): string {
}
interface ReplaceHistory {
replace(location: { pathname: string; search: string }): void;
replace(href: string): void;
}
/**
* Update the URL to remove successfully matched filters, keeping only unmatched ones.
* 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`.
* 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`.
*/
export function updateUrlWithUnmatchedFilters(
unmatchedFilters: RisonFilter[],
@@ -358,10 +358,7 @@ export function updateUrlWithUnmatchedFilters(
currentUrl.toString(),
);
if (history) {
history.replace({
pathname: currentUrl.pathname,
search: currentUrl.search,
});
history.replace(`${currentUrl.pathname}${currentUrl.search}`);
}
} catch (error) {
console.warn('Failed to update URL with unmatched filters:', error);

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