mirror of
https://github.com/apache/superset.git
synced 2026-06-11 10:39:15 +00:00
Compare commits
5 Commits
dependabot
...
fix/chart-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d796fc37de | ||
|
|
c5a683a589 | ||
|
|
f915b46be7 | ||
|
|
186aa99ecb | ||
|
|
2f0e4861a4 |
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -41,8 +41,8 @@ body:
|
||||
label: Superset version
|
||||
options:
|
||||
- master / latest-dev
|
||||
- "6.1.0"
|
||||
- "6.0.0"
|
||||
- "5.0.0"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
28
.github/dependabot.yml
vendored
28
.github/dependabot.yml
vendored
@@ -14,6 +14,12 @@ updates:
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
ignore:
|
||||
# TODO: remove below entries until React >= 18.0.0
|
||||
- dependency-name: "storybook"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
- dependency-name: "@storybook*"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
- dependency-name: "eslint-plugin-storybook"
|
||||
- dependency-name: "react-error-boundary"
|
||||
- dependency-name: "@rjsf/*"
|
||||
# remark-gfm v4+ requires react-markdown v9+, which needs React 18
|
||||
@@ -36,6 +42,14 @@ updates:
|
||||
# and confirm the issue https://github.com/apache/superset/issues/39600 is fixed
|
||||
- dependency-name: "react-checkbox-tree"
|
||||
update-types: ["version-update:semver-major"]
|
||||
groups:
|
||||
storybook:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "@storybook*"
|
||||
- "storybook"
|
||||
update-types:
|
||||
- "patch"
|
||||
directory: "/superset-frontend/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
@@ -76,7 +90,21 @@ updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/docs/"
|
||||
ignore:
|
||||
# TODO: remove below entries until React >= 18.0.0 in superset-frontend
|
||||
- dependency-name: "storybook"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
- dependency-name: "@storybook*"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
- dependency-name: "eslint-plugin-storybook"
|
||||
- dependency-name: "react-error-boundary"
|
||||
groups:
|
||||
storybook:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "@storybook*"
|
||||
- "storybook"
|
||||
update-types:
|
||||
- "patch"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -74,6 +74,6 @@ jobs:
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
33
UPDATING.md
33
UPDATING.md
@@ -24,27 +24,6 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### Map chart renderer and OpenStreetMap migration behavior
|
||||
|
||||
The MapLibre migration for deck.gl charts preserves saved non-Mapbox styles on
|
||||
the MapLibre-compatible path. Saved styles such as OpenStreetMap, `tile://`
|
||||
tile templates, generic HTTPS style URLs, and charts without a saved style are
|
||||
not reclassified as Mapbox during migration and do not require
|
||||
`MAPBOX_API_KEY` only because of the migration.
|
||||
|
||||
Saved true Mapbox styles whose value starts with `mapbox://` remain
|
||||
Mapbox-backed. If a Superset deployment does not configure `MAPBOX_API_KEY`,
|
||||
those saved Mapbox charts keep the existing missing-key message instead of
|
||||
silently falling back to MapLibre or another provider. In Explore, deck.gl and
|
||||
point-cluster renderer controls preserve saved Mapbox state, but the Mapbox
|
||||
choice is not available as a new working renderer without a configured key.
|
||||
|
||||
The MapLibre style choices include `Streets (OSM)`, backed by
|
||||
`https://tile.openstreetmap.org/{z}/{x}/{y}.png`. This OpenStreetMap tile
|
||||
service requires visible `© OpenStreetMap contributors` attribution and should
|
||||
be used through normal browser map tile requests and caching; it is not intended
|
||||
for bulk prefetch or offline tile downloads.
|
||||
|
||||
### Duration formatter precision
|
||||
|
||||
The `DURATION` number formatter now uses `Intl.DurationFormat` for locale-aware output. By default, sub-second fields are omitted, so values that previously displayed fractional seconds with `pretty-ms`, such as `10500` milliseconds rendering as `10.5s`, now render as `10s`.
|
||||
@@ -79,18 +58,6 @@ GLOBAL_ASYNC_QUERIES_JWT_SECRET = "<output of: openssl rand -base64 42>"
|
||||
|
||||
The check is only active when the relevant feature is enabled, so deployments that do not use global async queries (or embedding) are not affected.
|
||||
|
||||
### Guest token revocation (opt-in)
|
||||
|
||||
Embedded guest tokens can be coarsely revoked at runtime via a new opt-in mechanism. A new config flag `GUEST_TOKEN_REVOCATION_ENABLED` (default `False`) gates the feature. When enabled, every minted guest token carries a revocation version, and tokens whose version is below the current expected version (stored in the metadata database) are rejected at validation time.
|
||||
|
||||
Bump the expected version with the new CLI command to invalidate all outstanding guest tokens:
|
||||
|
||||
```bash
|
||||
superset revoke-guest-tokens
|
||||
```
|
||||
|
||||
This change is backward compatible. The feature is off by default, and even when enabled nothing is revoked until an admin explicitly bumps the version: the expected version starts at `0`, and tokens minted before this change (which carry no version claim) are treated as version `0`. No database migration is required.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -121,7 +121,7 @@ bigquery = [
|
||||
"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 = [
|
||||
@@ -161,7 +161,7 @@ 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"]
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { dirname, join } from 'path';
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
@@ -16,16 +17,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
// This file has been automatically migrated to valid ESM format by Storybook.
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
// Superset's webpack.config.js
|
||||
import customConfig from '../webpack.config.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const customConfig = require('../webpack.config.js');
|
||||
|
||||
// Filter out plugins that shouldn't be included in Storybook's static build
|
||||
// ReactRefreshWebpackPlugin adds Fast Refresh code that requires a dev server runtime,
|
||||
@@ -83,7 +76,7 @@ const disableDevModeInRules = rules =>
|
||||
};
|
||||
});
|
||||
|
||||
export default {
|
||||
module.exports = {
|
||||
stories: [
|
||||
'../src/**/*.stories.tsx',
|
||||
'../packages/superset-ui-core/src/**/*.stories.tsx',
|
||||
@@ -91,8 +84,11 @@ export default {
|
||||
],
|
||||
|
||||
addons: [
|
||||
"@storybook/addon-links",
|
||||
"@storybook/addon-docs"
|
||||
getAbsolutePath('@storybook/addon-essentials'),
|
||||
getAbsolutePath('@storybook/addon-links'),
|
||||
'@mihkeleidast/storybook-addon-source',
|
||||
getAbsolutePath('@storybook/addon-controls'),
|
||||
getAbsolutePath('@storybook/addon-mdx-gfm'),
|
||||
],
|
||||
|
||||
staticDirs: ['../src/assets/images'],
|
||||
@@ -109,13 +105,11 @@ export default {
|
||||
alias: {
|
||||
...config.resolve?.alias,
|
||||
...customConfig.resolve?.alias,
|
||||
// Fix for Storybook 8.6.x with React 17 - resolve ESM module paths
|
||||
'react-dom/test-utils': require.resolve('react-dom/test-utils'),
|
||||
// Shared storybook utilities
|
||||
'@storybook-shared': path.join(__dirname, 'shared'),
|
||||
'@storybook-shared': join(__dirname, 'shared'),
|
||||
},
|
||||
fallback: {
|
||||
tty: false,
|
||||
vm: require.resolve('vm-browserify')
|
||||
}
|
||||
},
|
||||
plugins: [...config.plugins, ...filteredPlugins],
|
||||
}),
|
||||
@@ -125,11 +119,15 @@ export default {
|
||||
},
|
||||
|
||||
framework: {
|
||||
name: getAbsolutePath("@storybook/react-webpack5"),
|
||||
name: getAbsolutePath('@storybook/react-webpack5'),
|
||||
options: {},
|
||||
}
|
||||
},
|
||||
|
||||
docs: {
|
||||
autodocs: false,
|
||||
},
|
||||
};
|
||||
|
||||
function getAbsolutePath(value) {
|
||||
return path.dirname(require.resolve(path.join(value, 'package.json')));
|
||||
return dirname(require.resolve(join(value, 'package.json')));
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { withJsx } from '@mihkeleidast/storybook-addon-source';
|
||||
import { themeObject, css, exampleThemes } from '@apache-superset/core/theme';
|
||||
import { combineReducers, createStore, applyMiddleware, compose } from 'redux';
|
||||
import thunk from 'redux-thunk';
|
||||
@@ -113,12 +114,9 @@ const providerDecorator = Story => (
|
||||
</Provider>
|
||||
);
|
||||
|
||||
export const decorators = [themeDecorator, providerDecorator];
|
||||
export const decorators = [withJsx, themeDecorator, providerDecorator];
|
||||
|
||||
export const parameters = {
|
||||
docs: {
|
||||
codePanel: true,
|
||||
},
|
||||
paddings: {
|
||||
values: [
|
||||
{ name: 'None', value: '0px' },
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { useState, ReactNode, SyntheticEvent } from 'react';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import type { Decorator } from '@storybook/react-webpack5';
|
||||
import type { Decorator } from '@storybook/react';
|
||||
import { ResizeCallbackData } from 'react-resizable';
|
||||
import ResizablePanel, { Size } from './ResizablePanel';
|
||||
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -69,7 +69,7 @@ module.exports = {
|
||||
],
|
||||
coverageReporters: ['lcov', 'json-summary', 'html', 'text'],
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.)',
|
||||
'node_modules/(?!@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued)',
|
||||
],
|
||||
preset: 'ts-jest',
|
||||
transform: {
|
||||
|
||||
8851
superset-frontend/package-lock.json
generated
8851
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -164,8 +164,8 @@
|
||||
"@visx/scale": "^3.5.0",
|
||||
"@visx/tooltip": "^3.0.0",
|
||||
"@visx/xychart": "^3.5.1",
|
||||
"ag-grid-community": "35.3.1",
|
||||
"ag-grid-react": "35.3.1",
|
||||
"ag-grid-community": "35.3.0",
|
||||
"ag-grid-react": "35.3.0",
|
||||
"antd": "^5.26.0",
|
||||
"chrono-node": "^2.9.1",
|
||||
"classnames": "^2.2.5",
|
||||
@@ -178,7 +178,7 @@
|
||||
"echarts": "^5.6.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.3.5",
|
||||
"fuse.js": "^7.4.1",
|
||||
"fuse.js": "^7.3.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.6.0",
|
||||
"geostyler-data": "^1.1.0",
|
||||
@@ -261,14 +261,22 @@
|
||||
"@babel/types": "^7.29.7",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/jest": "^11.14.2",
|
||||
"@formatjs/intl-durationformat": "^0.10.13",
|
||||
"@formatjs/intl-durationformat": "^0.10.3",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@mihkeleidast/storybook-addon-source": "^1.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.4.2",
|
||||
"@storybook/addon-links": "10.4.2",
|
||||
"@storybook/react-webpack5": "10.4.2",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@storybook/addon-actions": "^8.6.18",
|
||||
"@storybook/addon-controls": "^8.6.18",
|
||||
"@storybook/addon-essentials": "^8.6.18",
|
||||
"@storybook/addon-links": "^8.6.18",
|
||||
"@storybook/addon-mdx-gfm": "^8.6.18",
|
||||
"@storybook/components": "^8.6.18",
|
||||
"@storybook/preview-api": "^8.6.18",
|
||||
"@storybook/react": "^8.6.18",
|
||||
"@storybook/react-webpack5": "^8.6.18",
|
||||
"@storybook/test": "^8.6.18",
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.40",
|
||||
"@swc/plugin-emotion": "^14.12.0",
|
||||
@@ -289,6 +297,7 @@
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
"@types/react-redux": "^7.1.10",
|
||||
"@types/react-resizable": "^4.0.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
"@types/react-window": "^1.8.8",
|
||||
@@ -325,7 +334,7 @@
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.4",
|
||||
"eslint-plugin-storybook": "10.4.2",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -355,7 +364,7 @@
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.4.2",
|
||||
"storybook": "8.6.18",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@visx/responsive": "^3.12.0",
|
||||
"ace-builds": "^1.44.0",
|
||||
"ag-grid-community": "35.3.1",
|
||||
"ag-grid-react": "35.3.1",
|
||||
"ag-grid-community": "35.3.0",
|
||||
"ag-grid-react": "35.3.0",
|
||||
"brace": "^0.11.1",
|
||||
"classnames": "^2.5.1",
|
||||
"core-js": "^3.49.0",
|
||||
@@ -43,7 +43,7 @@
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.8",
|
||||
"dompurify": "^3.4.7",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"jed": "^1.1.1",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { AutoComplete } from '.';
|
||||
import type { AutoCompleteProps } from './types';
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Breadcrumb } from '.';
|
||||
import type { BreadcrumbProps } from './types';
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { action } from 'storybook/actions';
|
||||
import { Meta, StoryFn } from '@storybook/react-webpack5';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { CachedLabel } from '.';
|
||||
import type { CacheLabelProps } from './types';
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useArgs } from 'storybook/preview-api';
|
||||
import { useArgs } from '@storybook/preview-api';
|
||||
import { useState } from 'react';
|
||||
import { Checkbox } from '.';
|
||||
import type { CheckboxProps, CheckboxChangeEvent } from './types';
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Meta, StoryFn } from '@storybook/react-webpack5';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Row, Col } from '@superset-ui/core/components';
|
||||
import { EmptyState, imageMap } from '.';
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { FaveStar } from '.';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import Slider from '@superset-ui/core/components/Slider/index';
|
||||
import { useState } from 'react';
|
||||
import { Row, Col } from '.';
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { IconButton } from '.';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { StoryObj } from '@storybook/react-webpack5';
|
||||
import type { StoryObj } from '@storybook/react';
|
||||
import { Input, InputNumber } from '.';
|
||||
import type { InputProps, InputNumberProps, TextAreaProps } from './types';
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { action } from 'storybook/actions';
|
||||
import { Meta, StoryFn } from '@storybook/react-webpack5';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import type { LabelType } from './types';
|
||||
import { Label, DatasetTypeLabel, PublishedLabel } from '.';
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { Menu } from '../Menu';
|
||||
import type { LayoutProps, SiderProps } from './types';
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { ListViewCard } from '.';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { StoryObj } from '@storybook/react-webpack5';
|
||||
import type { StoryObj } from '@storybook/react';
|
||||
import { css } from '@apache-superset/core/theme';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { Space } from '../Space';
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Meta, StoryFn } from '@storybook/react-webpack5';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { SafeMarkdown } from './SafeMarkdown';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Space } from '../Space';
|
||||
import { Skeleton, type SkeletonProps } from '.';
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useArgs } from 'storybook/preview-api';
|
||||
import { useArgs } from '@storybook/preview-api';
|
||||
import { Switch, type SwitchProps } from '.';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
*/
|
||||
import { useState, DragEvent } from 'react';
|
||||
|
||||
import type { Meta, StoryFn } from '@storybook/react-webpack5';
|
||||
import { action } from 'storybook/actions';
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import {
|
||||
ColumnsType,
|
||||
ETableAction,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { StoryFn, Meta } from '@storybook/react-webpack5';
|
||||
import { StoryFn, Meta } from '@storybook/react';
|
||||
import ActionCell from './index';
|
||||
import { exampleMenuOptions, exampleRow } from './fixtures';
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { action } from 'storybook/actions';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { ActionMenuItem } from './index';
|
||||
|
||||
export const exampleMenuOptions: ActionMenuItem[] = [
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { StoryFn, Meta } from '@storybook/react-webpack5';
|
||||
import { StoryFn, Meta } from '@storybook/react';
|
||||
import BooleanCell from '.';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { StoryFn, Meta } from '@storybook/react-webpack5';
|
||||
import { action } from 'storybook/actions';
|
||||
import { StoryFn, Meta } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { ButtonCell } from './index';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { StoryFn, Meta } from '@storybook/react-webpack5';
|
||||
import { StoryFn, Meta } from '@storybook/react';
|
||||
import NullCell from '.';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { StoryFn, Meta } from '@storybook/react-webpack5';
|
||||
import { StoryFn, Meta } from '@storybook/react';
|
||||
import { CurrencyCode, NumericCell, LocaleCode, Style } from './index';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { StoryFn, Meta } from '@storybook/react-webpack5';
|
||||
import { StoryFn, Meta } from '@storybook/react';
|
||||
import { TimeFormats } from '@superset-ui/core';
|
||||
import TimeCell from '.';
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { Meta, StoryFn } from '@storybook/react-webpack5';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import {
|
||||
useTable,
|
||||
useSortBy,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useArgs } from 'storybook/preview-api';
|
||||
import { useArgs } from '@storybook/preview-api';
|
||||
import TimezoneSelector, { TimezoneSelectorProps } from './index';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { StoryObj } from '@storybook/react-webpack5';
|
||||
import { StoryObj } from '@storybook/react';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import Tree, { TreeProps, type TreeDataNode } from './index';
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { TreeSelect, type TreeSelectProps } from '.';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Typography } from '.';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { StoryObj } from '@storybook/react-webpack5';
|
||||
import type { StoryObj } from '@storybook/react';
|
||||
import { Icons } from '../Icons';
|
||||
import { Button } from '../Button';
|
||||
import { Upload } from '.';
|
||||
|
||||
@@ -35,4 +35,3 @@ export * from './typedMemo';
|
||||
export * from './html';
|
||||
export * from './tooltip';
|
||||
export * from './merge';
|
||||
export * from './mapStyles';
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
getBootstrapDataFromDocument,
|
||||
getDefaultMapRenderer,
|
||||
getMapProviderMapStyle,
|
||||
getMapboxApiKeyFromBootstrap,
|
||||
getMapRendererOptions,
|
||||
hasMapboxApiKey,
|
||||
isRasterTileTemplate,
|
||||
OSM_TILE_ATTRIBUTION,
|
||||
OSM_TILE_STYLE_URL,
|
||||
resolveMapStyle,
|
||||
} from './mapStyles';
|
||||
|
||||
test('OSM style metadata uses the approved URL and attribution', () => {
|
||||
expect(OSM_TILE_STYLE_URL).toBe(
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(OSM_TILE_ATTRIBUTION).toBe('© OpenStreetMap contributors');
|
||||
});
|
||||
|
||||
test('Mapbox key helpers report absence and presence from bootstrap data', () => {
|
||||
expect(getMapboxApiKeyFromBootstrap({ common: { conf: {} } })).toBe('');
|
||||
expect(hasMapboxApiKey({ common: { conf: {} } })).toBe(false);
|
||||
expect(
|
||||
getMapboxApiKeyFromBootstrap({
|
||||
common: { conf: { MAPBOX_API_KEY: 'pk.test' } },
|
||||
}),
|
||||
).toBe('pk.test');
|
||||
expect(
|
||||
getMapboxApiKeyFromBootstrap({
|
||||
common: { conf: { MAPBOX_API_KEY: ' pk.test ' } },
|
||||
}),
|
||||
).toBe('pk.test');
|
||||
expect(hasMapboxApiKey({ common: { conf: { MAPBOX_API_KEY: ' ' } } })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
hasMapboxApiKey({ common: { conf: { MAPBOX_API_KEY: 'pk.test' } } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('bootstrap data helper parses document data safely', () => {
|
||||
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
|
||||
common: { conf: { MAPBOX_API_KEY: 'pk.document' } },
|
||||
})}'></div>`;
|
||||
|
||||
expect(getBootstrapDataFromDocument()).toEqual({
|
||||
common: { conf: { MAPBOX_API_KEY: 'pk.document' } },
|
||||
});
|
||||
|
||||
document.body.innerHTML = `<div id="app" data-bootstrap='not-json'></div>`;
|
||||
expect(getBootstrapDataFromDocument()).toBeUndefined();
|
||||
|
||||
document.body.innerHTML = '';
|
||||
expect(getBootstrapDataFromDocument()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('renderer options enable Mapbox only when a key is available', () => {
|
||||
expect(getMapRendererOptions({ hasMapboxKey: true })).toEqual([
|
||||
{ value: 'maplibre' },
|
||||
{ value: 'mapbox' },
|
||||
]);
|
||||
expect(getMapRendererOptions({ hasMapboxKey: false })).toEqual([
|
||||
{ value: 'maplibre' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('renderer options preserve saved Mapbox without API-key labels', () => {
|
||||
expect(
|
||||
getMapRendererOptions({ hasMapboxKey: false, currentValue: 'mapbox' }),
|
||||
).toEqual([{ value: 'maplibre' }, { value: 'mapbox', disabled: true }]);
|
||||
});
|
||||
|
||||
test('map provider style helper preserves legacy non-Mapbox styles for MapLibre', () => {
|
||||
expect(
|
||||
getMapProviderMapStyle({
|
||||
mapProvider: 'maplibre',
|
||||
maplibreStyle: undefined,
|
||||
mapboxStyle: OSM_TILE_STYLE_URL,
|
||||
legacyMapStyle: 'https://example.com/fallback-style.json',
|
||||
}),
|
||||
).toEqual({
|
||||
mapProvider: 'maplibre',
|
||||
mapStyle: OSM_TILE_STYLE_URL,
|
||||
});
|
||||
});
|
||||
|
||||
test('map provider style helper does not send Mapbox URLs to MapLibre', () => {
|
||||
expect(
|
||||
getMapProviderMapStyle({
|
||||
mapProvider: 'maplibre',
|
||||
mapboxStyle: 'mapbox://styles/mapbox/dark-v11',
|
||||
legacyMapStyle: 'https://example.com/fallback-style.json',
|
||||
}),
|
||||
).toEqual({
|
||||
mapProvider: 'maplibre',
|
||||
mapStyle: 'https://example.com/fallback-style.json',
|
||||
});
|
||||
});
|
||||
|
||||
test('map provider style helper uses Mapbox style when Mapbox is selected', () => {
|
||||
expect(
|
||||
getMapProviderMapStyle({
|
||||
mapProvider: 'mapbox',
|
||||
mapboxStyle: 'mapbox://styles/mapbox/dark-v11',
|
||||
legacyMapStyle: 'https://example.com/fallback-style.json',
|
||||
}),
|
||||
).toEqual({
|
||||
mapProvider: 'mapbox',
|
||||
mapStyle: 'mapbox://styles/mapbox/dark-v11',
|
||||
});
|
||||
});
|
||||
|
||||
test('default renderer uses configured Mapbox only when a key is available', () => {
|
||||
expect(
|
||||
getDefaultMapRenderer({
|
||||
common: {
|
||||
conf: {
|
||||
DEFAULT_MAP_RENDERER: 'mapbox',
|
||||
MAPBOX_API_KEY: 'pk.test',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe('mapbox');
|
||||
expect(
|
||||
getDefaultMapRenderer({
|
||||
common: { conf: { DEFAULT_MAP_RENDERER: 'mapbox' } },
|
||||
}),
|
||||
).toBe('maplibre');
|
||||
expect(
|
||||
getDefaultMapRenderer({
|
||||
common: {
|
||||
conf: {
|
||||
DEFAULT_MAP_RENDERER: 'invalid',
|
||||
MAPBOX_API_KEY: 'pk.test',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe('maplibre');
|
||||
});
|
||||
|
||||
test('raster tile templates resolve to MapLibre raster style objects with attribution', () => {
|
||||
const style = resolveMapStyle(OSM_TILE_STYLE_URL, 'default-style.json');
|
||||
|
||||
expect(style).toEqual({
|
||||
version: 8,
|
||||
sources: {
|
||||
'osm-raster-tiles': {
|
||||
type: 'raster',
|
||||
tiles: [OSM_TILE_STYLE_URL],
|
||||
tileSize: 256,
|
||||
attribution: OSM_TILE_ATTRIBUTION,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'osm-raster-layer',
|
||||
type: 'raster',
|
||||
source: 'osm-raster-tiles',
|
||||
minzoom: 0,
|
||||
maxzoom: 22,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('tile protocol raster templates are unwrapped before style resolution', () => {
|
||||
const style = resolveMapStyle(
|
||||
`tile://${OSM_TILE_STYLE_URL}`,
|
||||
'default-style.json',
|
||||
);
|
||||
|
||||
expect(typeof style).toBe('object');
|
||||
if (typeof style !== 'string') {
|
||||
expect(style.sources['osm-raster-tiles'].tiles).toEqual([
|
||||
OSM_TILE_STYLE_URL,
|
||||
]);
|
||||
expect(style.sources['osm-raster-tiles'].attribution).toBe(
|
||||
OSM_TILE_ATTRIBUTION,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenStreetMap subdomain raster templates receive OSM attribution', () => {
|
||||
const osmSubdomainTileUrl =
|
||||
'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
const style = resolveMapStyle(
|
||||
`tile://${osmSubdomainTileUrl}`,
|
||||
'default-style.json',
|
||||
);
|
||||
|
||||
expect(typeof style).toBe('object');
|
||||
if (typeof style !== 'string') {
|
||||
expect(style.sources['osm-raster-tiles'].tiles).toEqual([
|
||||
osmSubdomainTileUrl,
|
||||
]);
|
||||
expect(style.sources['osm-raster-tiles'].attribution).toBe(
|
||||
OSM_TILE_ATTRIBUTION,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('custom raster tile templates do not receive OSM attribution', () => {
|
||||
const customTileUrl = 'https://tiles.example.com/{z}/{x}/{y}.png';
|
||||
const style = resolveMapStyle(
|
||||
`tile://${customTileUrl}`,
|
||||
'default-style.json',
|
||||
);
|
||||
|
||||
expect(typeof style).toBe('object');
|
||||
if (typeof style !== 'string') {
|
||||
expect(style.sources['osm-raster-tiles'].tiles).toEqual([customTileUrl]);
|
||||
expect(style.sources['osm-raster-tiles']).not.toHaveProperty('attribution');
|
||||
}
|
||||
});
|
||||
|
||||
test('lookalike OpenStreetMap hostnames do not receive OSM attribution', () => {
|
||||
const lookalikeTileUrl =
|
||||
'https://openstreetmap.org.example.com/{z}/{x}/{y}.png';
|
||||
const style = resolveMapStyle(
|
||||
`tile://${lookalikeTileUrl}`,
|
||||
'default-style.json',
|
||||
);
|
||||
|
||||
expect(typeof style).toBe('object');
|
||||
if (typeof style !== 'string') {
|
||||
expect(style.sources['osm-raster-tiles'].tiles).toEqual([lookalikeTileUrl]);
|
||||
expect(style.sources['osm-raster-tiles']).not.toHaveProperty('attribution');
|
||||
}
|
||||
});
|
||||
|
||||
test('style JSON URLs pass through without raster wrapping', () => {
|
||||
const styleUrl = 'https://example.com/styles/custom-style.json';
|
||||
|
||||
expect(isRasterTileTemplate(undefined)).toBe(false);
|
||||
expect(isRasterTileTemplate(styleUrl)).toBe(false);
|
||||
expect(resolveMapStyle(styleUrl, 'default-style.json')).toBe(styleUrl);
|
||||
expect(resolveMapStyle(undefined, 'default-style.json')).toBe(
|
||||
'default-style.json',
|
||||
);
|
||||
});
|
||||
@@ -1,251 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export type MapProvider = 'maplibre' | 'mapbox';
|
||||
|
||||
export type MapRendererOption = {
|
||||
value: MapProvider;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type MapProviderMapStyle = {
|
||||
mapProvider?: unknown;
|
||||
maplibreStyle?: unknown;
|
||||
mapboxStyle?: unknown;
|
||||
legacyMapStyle?: unknown;
|
||||
};
|
||||
|
||||
export type SelectedMapProviderMapStyle = {
|
||||
mapProvider: MapProvider;
|
||||
mapStyle?: string;
|
||||
};
|
||||
|
||||
export type RasterTileMapStyle = {
|
||||
version: 8;
|
||||
sources: {
|
||||
[sourceId: string]: {
|
||||
type: 'raster';
|
||||
tiles: string[];
|
||||
tileSize: 256;
|
||||
attribution?: string;
|
||||
};
|
||||
};
|
||||
layers: [
|
||||
{
|
||||
id: string;
|
||||
type: 'raster';
|
||||
source: string;
|
||||
minzoom: 0;
|
||||
maxzoom: 22;
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export type ResolvedMapStyle = string | RasterTileMapStyle;
|
||||
|
||||
export const OSM_TILE_STYLE_URL =
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
export const OSM_TILE_ATTRIBUTION = '© OpenStreetMap contributors';
|
||||
|
||||
export const MAPLIBRE_RENDERER_OPTION: MapRendererOption = {
|
||||
value: 'maplibre',
|
||||
};
|
||||
export const MAPBOX_RENDERER_OPTION: MapRendererOption = {
|
||||
value: 'mapbox',
|
||||
};
|
||||
export const DISABLED_MAPBOX_RENDERER_OPTION: MapRendererOption = {
|
||||
...MAPBOX_RENDERER_OPTION,
|
||||
disabled: true,
|
||||
};
|
||||
|
||||
const TILE_PROTOCOL = 'tile://';
|
||||
const RASTER_SOURCE_ID = 'osm-raster-tiles';
|
||||
const RASTER_LAYER_ID = 'osm-raster-layer';
|
||||
|
||||
type BootstrapData = {
|
||||
common?: {
|
||||
conf?: {
|
||||
DEFAULT_MAP_RENDERER?: unknown;
|
||||
MAPBOX_API_KEY?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export function getBootstrapDataFromDocument(): unknown {
|
||||
if (typeof document === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const appContainer = document.getElementById('app');
|
||||
const dataBootstrap = appContainer?.getAttribute('data-bootstrap');
|
||||
return dataBootstrap ? JSON.parse(dataBootstrap) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getMapboxApiKeyFromBootstrap(
|
||||
bootstrapData: unknown = getBootstrapDataFromDocument(),
|
||||
): string {
|
||||
const mapboxApiKey = (bootstrapData as BootstrapData | undefined)?.common
|
||||
?.conf?.MAPBOX_API_KEY;
|
||||
return typeof mapboxApiKey === 'string' ? mapboxApiKey.trim() : '';
|
||||
}
|
||||
|
||||
export function hasMapboxApiKey(
|
||||
bootstrapData: unknown = getBootstrapDataFromDocument(),
|
||||
): boolean {
|
||||
return getMapboxApiKeyFromBootstrap(bootstrapData).trim().length > 0;
|
||||
}
|
||||
|
||||
export function getDefaultMapRenderer(
|
||||
bootstrapData: unknown = getBootstrapDataFromDocument(),
|
||||
): MapProvider {
|
||||
const conf = (bootstrapData as BootstrapData | undefined)?.common?.conf;
|
||||
const defaultRenderer = conf?.DEFAULT_MAP_RENDERER;
|
||||
|
||||
if (defaultRenderer === 'mapbox' && hasMapboxApiKey(bootstrapData)) {
|
||||
return 'mapbox';
|
||||
}
|
||||
|
||||
return 'maplibre';
|
||||
}
|
||||
|
||||
export function getMapRendererOptions({
|
||||
hasMapboxKey,
|
||||
currentValue,
|
||||
}: {
|
||||
hasMapboxKey: boolean;
|
||||
currentValue?: MapProvider;
|
||||
}): MapRendererOption[] {
|
||||
if (!hasMapboxKey && currentValue !== 'mapbox') {
|
||||
return [MAPLIBRE_RENDERER_OPTION];
|
||||
}
|
||||
|
||||
return [
|
||||
MAPLIBRE_RENDERER_OPTION,
|
||||
hasMapboxKey ? MAPBOX_RENDERER_OPTION : DISABLED_MAPBOX_RENDERER_OPTION,
|
||||
];
|
||||
}
|
||||
|
||||
function getNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isMapboxStyle(value: unknown): boolean {
|
||||
return getNonEmptyString(value)?.startsWith('mapbox://') ?? false;
|
||||
}
|
||||
|
||||
export function getMapProviderMapStyle({
|
||||
mapProvider,
|
||||
maplibreStyle,
|
||||
mapboxStyle,
|
||||
legacyMapStyle,
|
||||
}: MapProviderMapStyle): SelectedMapProviderMapStyle {
|
||||
const selectedMapProvider: MapProvider =
|
||||
mapProvider === 'mapbox' ? 'mapbox' : 'maplibre';
|
||||
const maplibreStyleValue = getNonEmptyString(maplibreStyle);
|
||||
const mapboxStyleValue = getNonEmptyString(mapboxStyle);
|
||||
const legacyMapStyleValue = getNonEmptyString(legacyMapStyle);
|
||||
|
||||
if (selectedMapProvider === 'mapbox') {
|
||||
return {
|
||||
mapProvider: selectedMapProvider,
|
||||
mapStyle: mapboxStyleValue ?? legacyMapStyleValue,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mapProvider: selectedMapProvider,
|
||||
mapStyle:
|
||||
maplibreStyleValue ??
|
||||
(isMapboxStyle(mapboxStyleValue) ? undefined : mapboxStyleValue) ??
|
||||
legacyMapStyleValue,
|
||||
};
|
||||
}
|
||||
|
||||
function unwrapTileProtocol(value: string): string {
|
||||
return value.startsWith(TILE_PROTOCOL)
|
||||
? value.slice(TILE_PROTOCOL.length)
|
||||
: value;
|
||||
}
|
||||
|
||||
export function isRasterTileTemplate(value: unknown): value is string {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const tileUrl = unwrapTileProtocol(value);
|
||||
return ['{z}', '{x}', '{y}'].every(templateParam =>
|
||||
tileUrl.includes(templateParam),
|
||||
);
|
||||
}
|
||||
|
||||
function isOpenStreetMapTileUrl(value: string): boolean {
|
||||
try {
|
||||
const hostname = new URL(value).hostname.toLowerCase();
|
||||
return (
|
||||
hostname === 'openstreetmap.org' ||
|
||||
hostname.endsWith('.openstreetmap.org')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRasterTileMapStyle(value: string): RasterTileMapStyle {
|
||||
const tileUrl = unwrapTileProtocol(value);
|
||||
const attribution = isOpenStreetMapTileUrl(tileUrl)
|
||||
? { attribution: OSM_TILE_ATTRIBUTION }
|
||||
: {};
|
||||
|
||||
return {
|
||||
version: 8,
|
||||
sources: {
|
||||
[RASTER_SOURCE_ID]: {
|
||||
type: 'raster',
|
||||
tiles: [tileUrl],
|
||||
tileSize: 256,
|
||||
...attribution,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: RASTER_LAYER_ID,
|
||||
type: 'raster',
|
||||
source: RASTER_SOURCE_ID,
|
||||
minzoom: 0,
|
||||
maxzoom: 22,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMapStyle(
|
||||
value: string | undefined,
|
||||
defaultStyle: string,
|
||||
): ResolvedMapStyle {
|
||||
if (!value) {
|
||||
return defaultStyle;
|
||||
}
|
||||
|
||||
return isRasterTileTemplate(value) ? buildRasterTileMapStyle(value) : value;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ test('format milliseconds in human readable format with default options', () =>
|
||||
});
|
||||
test('format seconds in human readable format with default options', () => {
|
||||
const formatter = createDurationFormatter({ multiplier: 1000 });
|
||||
expect(formatter(-0.5)).toBe('0s');
|
||||
expect(formatter(-0.5)).toBe('-0s');
|
||||
expect(formatter(0.5)).toBe('0s');
|
||||
expect(formatter(1)).toBe('1s');
|
||||
expect(formatter(30)).toBe('30s');
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.18.1",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
"dompurify": "^3.4.8",
|
||||
"dompurify": "^3.4.7",
|
||||
"prop-types": "^15.8.1",
|
||||
"urijs": "^1.19.11"
|
||||
},
|
||||
|
||||
@@ -20,10 +20,6 @@ import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { Map as MapLibreMap } from 'react-map-gl/maplibre';
|
||||
import { Map as MapboxMap } from 'react-map-gl/mapbox';
|
||||
import { WebMercatorViewport } from '@math.gl/web-mercator';
|
||||
import {
|
||||
resolveMapStyle,
|
||||
type ResolvedMapStyle,
|
||||
} from '@superset-ui/core/utils/mapStyles';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import ScatterPlotOverlay from './components/ScatterPlotOverlay';
|
||||
@@ -164,10 +160,7 @@ function MapLibre({
|
||||
const clusters = clusterer.getClusters(bbox, Math.round(viewport.zoom));
|
||||
|
||||
const theme = useTheme();
|
||||
const resolvedMapStyle: ResolvedMapStyle =
|
||||
mapProvider === 'mapbox'
|
||||
? mapStyle || DEFAULT_MAP_STYLE
|
||||
: resolveMapStyle(mapStyle, DEFAULT_MAP_STYLE);
|
||||
const resolvedMapStyle = mapStyle || DEFAULT_MAP_STYLE;
|
||||
const mapboxApiKey = mapProvider === 'mapbox' ? getMapboxApiKey() : '';
|
||||
|
||||
if (mapProvider === 'mapbox' && !mapboxApiKey) {
|
||||
|
||||
@@ -19,19 +19,11 @@
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
columnChoices,
|
||||
ControlPanelState,
|
||||
ControlPanelConfig,
|
||||
formatSelectOptions,
|
||||
sharedControls,
|
||||
getStandardizedControls,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import type { QueryFormData } from '@superset-ui/core';
|
||||
import type { MapProvider } from '@superset-ui/core/utils/mapStyles';
|
||||
import { getDefaultMapRenderer } from '@superset-ui/core/utils/mapStyles';
|
||||
import {
|
||||
getPointClusterMapRendererProps,
|
||||
POINT_CLUSTER_MAPLIBRE_STYLE_CHOICES,
|
||||
} from './utils/mapControls';
|
||||
|
||||
const columnsConfig = sharedControls.entity;
|
||||
|
||||
@@ -43,11 +35,6 @@ const colorChoices = [
|
||||
['#dc143c', t('Crimson')],
|
||||
['#228b22', t('Forest Green')],
|
||||
];
|
||||
type MapStyleVisibilityProps = {
|
||||
controls?: {
|
||||
map_renderer?: { value?: unknown };
|
||||
};
|
||||
};
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
@@ -122,7 +109,7 @@ const config: ControlPanelConfig = {
|
||||
'Either a numerical column or `Auto`, which scales the point based ' +
|
||||
'on the largest cluster',
|
||||
),
|
||||
mapStateToProps: (state: ControlPanelState) => {
|
||||
mapStateToProps: (state: any) => {
|
||||
const datasourceChoices = columnChoices(state.datasource);
|
||||
const choices: [string, string][] = [['Auto', t('Auto')]];
|
||||
return {
|
||||
@@ -169,7 +156,7 @@ const config: ControlPanelConfig = {
|
||||
'Non-numerical columns will be used to label points. ' +
|
||||
'Leave empty to get a count of points in each cluster.',
|
||||
),
|
||||
mapStateToProps: (state: ControlPanelState) => ({
|
||||
mapStateToProps: (state: any) => ({
|
||||
choices: columnChoices(state.datasource),
|
||||
}),
|
||||
},
|
||||
@@ -213,17 +200,14 @@ const config: ControlPanelConfig = {
|
||||
label: t('Map Renderer'),
|
||||
clearable: false,
|
||||
renderTrigger: true,
|
||||
options: getPointClusterMapRendererProps().options,
|
||||
choices: [
|
||||
['maplibre', t('MapLibre (open-source)')],
|
||||
['mapbox', t('Mapbox (API key required)')],
|
||||
],
|
||||
default: 'maplibre',
|
||||
description: t(
|
||||
'MapLibre is open-source and requires no API key. Mapbox requires MAPBOX_API_KEY to be configured on the server.',
|
||||
),
|
||||
mapStateToProps: (state: ControlPanelState) => ({
|
||||
...getPointClusterMapRendererProps(
|
||||
state.form_data?.map_renderer as MapProvider | undefined,
|
||||
),
|
||||
default: getDefaultMapRenderer(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -236,13 +220,30 @@ const config: ControlPanelConfig = {
|
||||
clearable: false,
|
||||
renderTrigger: true,
|
||||
freeForm: true,
|
||||
choices: POINT_CLUSTER_MAPLIBRE_STYLE_CHOICES,
|
||||
choices: [
|
||||
[
|
||||
'https://tiles.openfreemap.org/styles/liberty',
|
||||
t('Liberty (OpenFreeMap)'),
|
||||
],
|
||||
[
|
||||
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||
t('Light (Carto)'),
|
||||
],
|
||||
[
|
||||
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
|
||||
t('Dark (Carto)'),
|
||||
],
|
||||
[
|
||||
'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
|
||||
t('Streets (Carto)'),
|
||||
],
|
||||
],
|
||||
default: 'https://tiles.openfreemap.org/styles/liberty',
|
||||
description: t(
|
||||
'Base layer map style. See MapLibre documentation: %s',
|
||||
'https://maplibre.org/maplibre-style-spec/',
|
||||
),
|
||||
visibility: ({ controls }: MapStyleVisibilityProps) =>
|
||||
visibility: ({ controls }: any) =>
|
||||
controls?.map_renderer?.value !== 'mapbox',
|
||||
},
|
||||
},
|
||||
@@ -271,7 +272,7 @@ const config: ControlPanelConfig = {
|
||||
description: t(
|
||||
'Base layer map style. Accepts a Mapbox style URL (mapbox://styles/...).',
|
||||
),
|
||||
visibility: ({ controls }: MapStyleVisibilityProps) =>
|
||||
visibility: ({ controls }: any) =>
|
||||
controls?.map_renderer?.value === 'mapbox',
|
||||
},
|
||||
},
|
||||
@@ -386,7 +387,7 @@ const config: ControlPanelConfig = {
|
||||
),
|
||||
},
|
||||
},
|
||||
formDataOverrides: (formData: QueryFormData) => ({
|
||||
formDataOverrides: (formData: any) => ({
|
||||
...formData,
|
||||
groupby: getStandardizedControls().popAllColumns(),
|
||||
}),
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import Supercluster, {
|
||||
type Options as SuperclusterOptions,
|
||||
} from 'supercluster';
|
||||
import { ChartProps, getMapProviderMapStyle } from '@superset-ui/core';
|
||||
import { ChartProps } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { DEFAULT_POINT_RADIUS, DEFAULT_MAX_ZOOM } from './MapLibre';
|
||||
import roundDecimal from './utils/roundDecimal';
|
||||
@@ -152,7 +152,6 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
map_renderer: mapProvider,
|
||||
maplibre_style: maplibreStyle,
|
||||
mapbox_style: mapboxStyle = '',
|
||||
map_style: legacyMapStyle,
|
||||
pandas_aggfunc: pandasAggfunc,
|
||||
point_radius: pointRadius,
|
||||
point_radius_unit: pointRadiusUnit,
|
||||
@@ -243,12 +242,6 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
const clusterer = new Supercluster<PointProperties, ClusterProperties>(opts);
|
||||
// Disable strict typecheck on load since Supercluster typings have namespace issues with esModuleInterop
|
||||
clusterer.load(geoJSON.features as any);
|
||||
const selectedMap = getMapProviderMapStyle({
|
||||
mapProvider,
|
||||
maplibreStyle,
|
||||
mapboxStyle,
|
||||
legacyMapStyle,
|
||||
});
|
||||
|
||||
return {
|
||||
width,
|
||||
@@ -258,8 +251,11 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
clusterer,
|
||||
globalOpacity: Math.min(1, Math.max(0, toFiniteNumber(globalOpacity) ?? 1)),
|
||||
hasCustomMetric,
|
||||
mapProvider: selectedMap.mapProvider,
|
||||
mapStyle: selectedMap.mapStyle,
|
||||
mapProvider,
|
||||
mapStyle:
|
||||
mapProvider === 'mapbox'
|
||||
? (mapboxStyle as string)
|
||||
: (maplibreStyle as string),
|
||||
onViewportChange({
|
||||
latitude,
|
||||
longitude,
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
getMapRendererOptions,
|
||||
OSM_TILE_STYLE_URL,
|
||||
type MapRendererOption,
|
||||
type MapProvider,
|
||||
} from '@superset-ui/core/utils/mapStyles';
|
||||
import { hasMapboxApiKey } from './mapbox';
|
||||
|
||||
export const POINT_CLUSTER_MAPLIBRE_STYLE_CHOICES = [
|
||||
['https://tiles.openfreemap.org/styles/liberty', t('Liberty (OpenFreeMap)')],
|
||||
[
|
||||
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||
t('Light (Carto)'),
|
||||
],
|
||||
[
|
||||
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
|
||||
t('Dark (Carto)'),
|
||||
],
|
||||
[
|
||||
'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
|
||||
t('Streets (Carto)'),
|
||||
],
|
||||
[OSM_TILE_STYLE_URL, t('Streets (OSM)')],
|
||||
];
|
||||
|
||||
export function getPointClusterMapRendererProps(currentValue?: MapProvider) {
|
||||
const hasKey = hasMapboxApiKey();
|
||||
return {
|
||||
options: getMapRendererOptions({
|
||||
hasMapboxKey: hasKey,
|
||||
currentValue,
|
||||
}).map((option: MapRendererOption) => ({
|
||||
...option,
|
||||
label:
|
||||
option.value === 'maplibre'
|
||||
? t('MapLibre (open-source)')
|
||||
: t('Mapbox (API key required)'),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -17,15 +17,19 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
getMapboxApiKeyFromBootstrap,
|
||||
hasMapboxApiKey as hasBootstrapMapboxApiKey,
|
||||
} from '@superset-ui/core/utils/mapStyles';
|
||||
|
||||
export function getMapboxApiKey(): string {
|
||||
return getMapboxApiKeyFromBootstrap();
|
||||
}
|
||||
|
||||
export function hasMapboxApiKey(): boolean {
|
||||
return hasBootstrapMapboxApiKey();
|
||||
if (typeof document === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const appContainer = document.getElementById('app');
|
||||
const dataBootstrap = appContainer?.getAttribute('data-bootstrap');
|
||||
if (dataBootstrap) {
|
||||
const bootstrapData = JSON.parse(dataBootstrap);
|
||||
return bootstrapData?.common?.conf?.MAPBOX_API_KEY || '';
|
||||
}
|
||||
} catch {
|
||||
// If bootstrap data is unavailable or malformed, return empty string
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -18,12 +18,7 @@
|
||||
*/
|
||||
|
||||
import { type ReactNode } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import {
|
||||
OSM_TILE_ATTRIBUTION,
|
||||
OSM_TILE_STYLE_URL,
|
||||
} from '@superset-ui/core/utils/mapStyles';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
// Capture the most recent viewport props passed to the Map component
|
||||
let lastMapProps: Record<string, unknown> = {};
|
||||
@@ -96,7 +91,6 @@ const defaultProps = {
|
||||
|
||||
beforeEach(() => {
|
||||
lastMapProps = {};
|
||||
document.body.innerHTML = '';
|
||||
jest.clearAllMocks();
|
||||
mockFitBounds.mockImplementation(
|
||||
(
|
||||
@@ -189,65 +183,6 @@ test('passes globalOpacity to ScatterPlotOverlay', () => {
|
||||
expect(overlay!.getAttribute('data-opacity')).toBe('0.5');
|
||||
});
|
||||
|
||||
test('converts OSM raster tile templates into MapLibre style objects', () => {
|
||||
render(<MapLibre {...defaultProps} mapStyle={OSM_TILE_STYLE_URL} />);
|
||||
|
||||
expect(lastMapProps.mapStyle).toEqual({
|
||||
version: 8,
|
||||
sources: {
|
||||
'osm-raster-tiles': {
|
||||
type: 'raster',
|
||||
tiles: [OSM_TILE_STYLE_URL],
|
||||
tileSize: 256,
|
||||
attribution: OSM_TILE_ATTRIBUTION,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'osm-raster-layer',
|
||||
type: 'raster',
|
||||
source: 'osm-raster-tiles',
|
||||
minzoom: 0,
|
||||
maxzoom: 22,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps the missing Mapbox key signal for saved Mapbox charts', () => {
|
||||
render(
|
||||
<MapLibre
|
||||
{...defaultProps}
|
||||
mapProvider="mapbox"
|
||||
mapStyle="mapbox://styles/mapbox/dark-v11"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Mapbox requires a MAPBOX_API_KEY to be configured on the server.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(lastMapProps.mapStyle).toBeUndefined();
|
||||
});
|
||||
|
||||
test('passes Mapbox styles through when a key exists', () => {
|
||||
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
|
||||
common: { conf: { MAPBOX_API_KEY: 'pk.test' } },
|
||||
})}'></div>`;
|
||||
|
||||
render(
|
||||
<MapLibre
|
||||
{...defaultProps}
|
||||
mapProvider="mapbox"
|
||||
mapStyle="mapbox://styles/mapbox/dark-v11"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastMapProps.mapStyle).toBe('mapbox://styles/mapbox/dark-v11');
|
||||
expect(lastMapProps.mapboxAccessToken).toBe('pk.test');
|
||||
});
|
||||
|
||||
test('handles undefined bounds gracefully', () => {
|
||||
render(<MapLibre {...defaultProps} bounds={undefined} />);
|
||||
expect(lastMapProps.longitude).toBe(0);
|
||||
|
||||
@@ -17,11 +17,9 @@
|
||||
* under the License.
|
||||
*/
|
||||
import type {
|
||||
ControlPanelState,
|
||||
ControlPanelConfig,
|
||||
CustomControlItem,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { OSM_TILE_STYLE_URL } from '@superset-ui/core/utils/mapStyles';
|
||||
import controlPanel from '../src/controlPanel';
|
||||
|
||||
type ControlConfig = Required<CustomControlItem['config']>;
|
||||
@@ -56,27 +54,6 @@ function getControl(
|
||||
return item;
|
||||
}
|
||||
|
||||
type RendererControlConfig = ControlConfig & {
|
||||
mapStateToProps: (state: ControlPanelState) => {
|
||||
options?: unknown;
|
||||
warning?: string;
|
||||
default?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const setBootstrap = (conf: Record<string, unknown>) => {
|
||||
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
|
||||
common: { conf },
|
||||
})}'></div>`;
|
||||
};
|
||||
|
||||
const getMapRendererProps = (value?: string) =>
|
||||
(
|
||||
getControl(controlPanel, 'map_renderer').config as RendererControlConfig
|
||||
).mapStateToProps({
|
||||
form_data: { map_renderer: value },
|
||||
} as unknown as ControlPanelState);
|
||||
|
||||
test('viewport controls default to empty values and rerender without query refresh', () => {
|
||||
const longitudeControl = getControl(controlPanel, 'viewport_longitude');
|
||||
const latitudeControl = getControl(controlPanel, 'viewport_latitude');
|
||||
@@ -102,63 +79,3 @@ test('opacity control rerenders immediately when changed', () => {
|
||||
expect(opacityControl.config.renderTrigger).toBe(true);
|
||||
expect(opacityControl.config.isFloat).toBe(true);
|
||||
});
|
||||
|
||||
test('MapLibre style choices expose Streets (OSM)', () => {
|
||||
expect(
|
||||
getControl(controlPanel, 'maplibre_style').config.choices,
|
||||
).toContainEqual([OSM_TILE_STYLE_URL, 'Streets (OSM)']);
|
||||
});
|
||||
|
||||
test('map renderer hides Mapbox when no key exists for new selections', () => {
|
||||
setBootstrap({});
|
||||
|
||||
const props = getMapRendererProps('maplibre');
|
||||
|
||||
expect(props.options).toEqual([
|
||||
{ value: 'maplibre', label: 'MapLibre (open-source)' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('map renderer keeps saved Mapbox visible while disabled without a key', () => {
|
||||
setBootstrap({});
|
||||
|
||||
const props = getMapRendererProps('mapbox');
|
||||
|
||||
expect(props.options).toContainEqual({
|
||||
value: 'mapbox',
|
||||
label: 'Mapbox (API key required)',
|
||||
disabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('map renderer enables Mapbox when a key exists', () => {
|
||||
setBootstrap({ MAPBOX_API_KEY: 'pk.test' });
|
||||
|
||||
const props = getMapRendererProps('maplibre');
|
||||
|
||||
expect(props.options).toEqual([
|
||||
{ value: 'maplibre', label: 'MapLibre (open-source)' },
|
||||
{ value: 'mapbox', label: 'Mapbox (API key required)' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('map renderer keeps the original explanatory description', () => {
|
||||
expect(getControl(controlPanel, 'map_renderer').config.description).toBe(
|
||||
'MapLibre is open-source and requires no API key. Mapbox requires MAPBOX_API_KEY to be configured on the server.',
|
||||
);
|
||||
});
|
||||
|
||||
test('map renderer defaults to configured Mapbox when a key exists', () => {
|
||||
setBootstrap({
|
||||
DEFAULT_MAP_RENDERER: 'mapbox',
|
||||
MAPBOX_API_KEY: 'pk.test',
|
||||
});
|
||||
|
||||
expect(getMapRendererProps('maplibre').default).toBe('mapbox');
|
||||
});
|
||||
|
||||
test('map renderer falls back from configured Mapbox default without a key', () => {
|
||||
setBootstrap({ DEFAULT_MAP_RENDERER: 'mapbox' });
|
||||
|
||||
expect(getMapRendererProps('maplibre').default).toBe('maplibre');
|
||||
});
|
||||
|
||||
@@ -34,8 +34,6 @@ import transformProps from '../src/transformProps';
|
||||
|
||||
type TransformPropsResult = {
|
||||
globalOpacity?: number;
|
||||
mapProvider?: string;
|
||||
mapStyle?: string;
|
||||
onViewportChange?: (viewport: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
@@ -217,41 +215,6 @@ test('passes through numeric values unchanged', () => {
|
||||
expect(result.globalOpacity).toBe(0.8);
|
||||
});
|
||||
|
||||
test('uses the MapLibre style when maplibre renderer is selected', () => {
|
||||
const result = getTransformPropsResult({
|
||||
map_renderer: 'maplibre',
|
||||
maplibre_style: 'https://example.com/maplibre-style.json',
|
||||
mapbox_style: 'mapbox://styles/mapbox/dark-v11',
|
||||
});
|
||||
|
||||
expect(result.mapProvider).toBe('maplibre');
|
||||
expect(result.mapStyle).toBe('https://example.com/maplibre-style.json');
|
||||
});
|
||||
|
||||
test('uses legacy non-Mapbox style for MapLibre when provider style is absent', () => {
|
||||
const result = getTransformPropsResult({
|
||||
map_renderer: 'maplibre',
|
||||
maplibre_style: undefined,
|
||||
mapbox_style: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
});
|
||||
|
||||
expect(result.mapProvider).toBe('maplibre');
|
||||
expect(result.mapStyle).toBe(
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('uses the Mapbox style when mapbox renderer is selected', () => {
|
||||
const result = getTransformPropsResult({
|
||||
map_renderer: 'mapbox',
|
||||
maplibre_style: 'https://example.com/maplibre-style.json',
|
||||
mapbox_style: 'mapbox://styles/mapbox/dark-v11',
|
||||
});
|
||||
|
||||
expect(result.mapProvider).toBe('mapbox');
|
||||
expect(result.mapStyle).toBe('mapbox://styles/mapbox/dark-v11');
|
||||
});
|
||||
|
||||
test('calls onError and falls back to black for invalid color', () => {
|
||||
const onError = jest.fn();
|
||||
const chartProps = new ChartProps({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
JsonValue,
|
||||
QueryFormData,
|
||||
SetDataMaskHook,
|
||||
getMapProviderMapStyle,
|
||||
} from '@superset-ui/core';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import Legend from './components/Legend';
|
||||
@@ -319,12 +318,6 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
|
||||
},
|
||||
[categories],
|
||||
);
|
||||
const selectedMap = getMapProviderMapStyle({
|
||||
mapProvider: props.formData.map_renderer,
|
||||
maplibreStyle: props.formData.maplibre_style,
|
||||
mapboxStyle: props.formData.mapbox_style,
|
||||
legacyMapStyle: props.formData.map_style,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -333,8 +326,14 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
|
||||
viewport={viewport}
|
||||
layers={getLayers()}
|
||||
setControlValue={props.setControlValue}
|
||||
mapStyle={selectedMap.mapStyle}
|
||||
mapProvider={selectedMap.mapProvider}
|
||||
mapStyle={
|
||||
props.formData.map_renderer === 'mapbox'
|
||||
? props.formData.mapbox_style
|
||||
: props.formData.maplibre_style
|
||||
}
|
||||
mapProvider={
|
||||
props.formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'
|
||||
}
|
||||
mapboxApiKey={getMapboxApiKey()}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { ComponentProps, createRef, ReactNode } from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
|
||||
import {
|
||||
OSM_TILE_ATTRIBUTION,
|
||||
OSM_TILE_STYLE_URL,
|
||||
} from '@superset-ui/core/utils/mapStyles';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { DeckGLContainer, DeckGLContainerHandle } from './DeckGLContainer';
|
||||
|
||||
jest.mock('react-map-gl/maplibre', () => ({
|
||||
Map: ({
|
||||
children,
|
||||
mapStyle,
|
||||
onMove,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
mapStyle: unknown;
|
||||
onMove: (evt: { viewState: Record<string, number> }) => void;
|
||||
}) => (
|
||||
<div data-test="maplibre-map" data-map-style={JSON.stringify(mapStyle)}>
|
||||
<button
|
||||
type="button"
|
||||
data-test="maplibre-move"
|
||||
onClick={() =>
|
||||
onMove({ viewState: { longitude: 1, latitude: 2, zoom: 3 } })
|
||||
}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('react-map-gl/mapbox', () => ({
|
||||
Map: ({ children, mapStyle }: { children: ReactNode; mapStyle: unknown }) => (
|
||||
<div data-test="mapbox-map" data-map-style={JSON.stringify(mapStyle)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('mapbox-gl', () => ({ accessToken: '' }));
|
||||
|
||||
jest.mock(
|
||||
'./components/DeckGLOverlayMapLibre',
|
||||
() =>
|
||||
({ layers }: { layers: unknown[] }) => (
|
||||
<div data-test="maplibre-overlay" data-layers-count={layers.length} />
|
||||
),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'./components/DeckGLOverlayMapbox',
|
||||
() =>
|
||||
({ layers }: { layers: unknown[] }) => (
|
||||
<div data-test="mapbox-overlay" data-layers-count={layers.length} />
|
||||
),
|
||||
);
|
||||
|
||||
jest.mock('./components/Tooltip', () => ({
|
||||
__esModule: true,
|
||||
default: ({ variant = 'default' }: { variant?: 'default' | 'custom' }) => (
|
||||
<div data-test={`tooltip-${variant}`} />
|
||||
),
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
viewport: { longitude: 0, latitude: 0, zoom: 1, bearing: 0, pitch: 0 },
|
||||
width: 800,
|
||||
height: 600,
|
||||
layers: [],
|
||||
};
|
||||
|
||||
const renderContainer = (
|
||||
props: Partial<ComponentProps<typeof DeckGLContainer>>,
|
||||
) =>
|
||||
render(
|
||||
<ThemeProvider theme={supersetTheme}>
|
||||
<DeckGLContainer {...baseProps} {...props} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('DeckGLContainer converts OSM raster tile templates into MapLibre style objects', () => {
|
||||
renderContainer({ mapProvider: 'maplibre', mapStyle: OSM_TILE_STYLE_URL });
|
||||
|
||||
const style = JSON.parse(
|
||||
screen.getByTestId('maplibre-map').getAttribute('data-map-style') || '{}',
|
||||
);
|
||||
|
||||
expect(style.sources['osm-raster-tiles']).toEqual({
|
||||
type: 'raster',
|
||||
tiles: [OSM_TILE_STYLE_URL],
|
||||
tileSize: 256,
|
||||
attribution: OSM_TILE_ATTRIBUTION,
|
||||
});
|
||||
expect(style.layers[0]).toMatchObject({
|
||||
id: 'osm-raster-layer',
|
||||
type: 'raster',
|
||||
source: 'osm-raster-tiles',
|
||||
});
|
||||
});
|
||||
|
||||
test('DeckGLContainer passes style JSON URLs through to MapLibre', () => {
|
||||
const styleUrl = 'https://example.com/styles/custom-style.json';
|
||||
|
||||
renderContainer({ mapProvider: 'maplibre', mapStyle: styleUrl });
|
||||
|
||||
expect(screen.getByTestId('maplibre-map')).toHaveAttribute(
|
||||
'data-map-style',
|
||||
JSON.stringify(styleUrl),
|
||||
);
|
||||
});
|
||||
|
||||
test('DeckGLContainer keeps the missing Mapbox key signal for saved Mapbox charts', () => {
|
||||
renderContainer({
|
||||
mapProvider: 'mapbox',
|
||||
mapStyle: 'mapbox://styles/mapbox/dark-v9',
|
||||
mapboxApiKey: '',
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Mapbox requires a MAPBOX_API_KEY to be configured on the server.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('maplibre-map')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('mapbox-map')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('DeckGLContainer passes Mapbox styles through when a key exists', () => {
|
||||
renderContainer({
|
||||
mapProvider: 'mapbox',
|
||||
mapStyle: 'mapbox://styles/mapbox/dark-v9',
|
||||
mapboxApiKey: 'pk.test',
|
||||
});
|
||||
|
||||
expect(mapboxgl.accessToken).toBe('pk.test');
|
||||
expect(screen.getByTestId('mapbox-map')).toHaveAttribute(
|
||||
'data-map-style',
|
||||
JSON.stringify('mapbox://styles/mapbox/dark-v9'),
|
||||
);
|
||||
});
|
||||
|
||||
test('DeckGLContainer supports layer factories for MapLibre overlays', () => {
|
||||
const layer = { id: 'layer-1' } as unknown as Layer;
|
||||
const layerFactory = () => layer;
|
||||
|
||||
renderContainer({ mapProvider: 'maplibre', layers: [layerFactory] });
|
||||
|
||||
expect(screen.getByTestId('maplibre-overlay')).toHaveAttribute(
|
||||
'data-layers-count',
|
||||
'1',
|
||||
);
|
||||
});
|
||||
|
||||
test('DeckGLContainer updates viewport controls after map movement is throttled', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(1000);
|
||||
const setControlValue = jest.fn();
|
||||
|
||||
renderContainer({ mapProvider: 'maplibre', setControlValue });
|
||||
fireEvent.click(screen.getByTestId('maplibre-move'));
|
||||
|
||||
jest.setSystemTime(1301);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
expect(setControlValue).toHaveBeenCalledWith('viewport', {
|
||||
longitude: 1,
|
||||
latitude: 2,
|
||||
zoom: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('DeckGLContainer suppresses the native context menu', () => {
|
||||
renderContainer({ mapProvider: 'maplibre' });
|
||||
|
||||
const event = new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
|
||||
const stopPropagationSpy = jest.spyOn(event, 'stopPropagation');
|
||||
|
||||
screen.getByTestId('maplibre-map').parentElement?.dispatchEvent(event);
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('DeckGLContainer renders default and custom tooltip variants through its ref', () => {
|
||||
const ref = createRef<DeckGLContainerHandle>();
|
||||
|
||||
render(
|
||||
<ThemeProvider theme={supersetTheme}>
|
||||
<DeckGLContainer {...baseProps} mapProvider="maplibre" ref={ref} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
ref.current?.setTooltip({ x: 0, y: 0, content: 'Default tooltip' });
|
||||
});
|
||||
expect(screen.getByTestId('tooltip-default')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
ref.current?.setTooltip({
|
||||
x: 0,
|
||||
y: 0,
|
||||
content: <span data-tooltip-type="custom">Custom tooltip</span>,
|
||||
});
|
||||
});
|
||||
expect(screen.getByTestId('tooltip-custom')).toBeInTheDocument();
|
||||
});
|
||||
@@ -33,11 +33,6 @@ import { Map as MapboxMap } from 'react-map-gl/mapbox';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import { JsonObject, JsonValue, usePrevious } from '@superset-ui/core';
|
||||
import {
|
||||
resolveMapStyle,
|
||||
type MapProvider,
|
||||
type ResolvedMapStyle,
|
||||
} from '@superset-ui/core/utils/mapStyles';
|
||||
import { styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import DeckGLOverlayMapLibre from './components/DeckGLOverlayMapLibre';
|
||||
@@ -55,7 +50,7 @@ export type DeckGLContainerProps = {
|
||||
viewport: Viewport;
|
||||
setControlValue?: (control: string, value: JsonValue) => void;
|
||||
mapStyle?: string;
|
||||
mapProvider?: MapProvider;
|
||||
mapProvider?: 'maplibre' | 'mapbox';
|
||||
mapboxApiKey?: string;
|
||||
children?: ReactNode;
|
||||
width: number;
|
||||
@@ -128,9 +123,7 @@ export const DeckGLContainer = memo(
|
||||
const theme = useTheme();
|
||||
const { children = null, height, width } = props;
|
||||
const isMapbox = props.mapProvider === 'mapbox';
|
||||
const mapStyle: ResolvedMapStyle = isMapbox
|
||||
? props.mapStyle || DEFAULT_MAP_STYLE
|
||||
: resolveMapStyle(props.mapStyle, DEFAULT_MAP_STYLE);
|
||||
const mapStyle = props.mapStyle || DEFAULT_MAP_STYLE;
|
||||
|
||||
if (isMapbox && !props.mapboxApiKey) {
|
||||
return (
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
QueryFormData,
|
||||
QueryObjectFilterClause,
|
||||
SupersetClient,
|
||||
getMapProviderMapStyle,
|
||||
usePrevious,
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
@@ -398,12 +397,6 @@ const DeckMulti = (props: DeckMultiProps) => {
|
||||
.filter(layer => layer !== undefined),
|
||||
[layerOrder, subSlicesLayers],
|
||||
);
|
||||
const selectedMap = getMapProviderMapStyle({
|
||||
mapProvider: formData.map_renderer,
|
||||
maplibreStyle: formData.maplibre_style,
|
||||
mapboxStyle: formData.mapbox_style,
|
||||
legacyMapStyle: formData.map_style,
|
||||
});
|
||||
|
||||
return (
|
||||
<MultiWrapper height={height} width={width}>
|
||||
@@ -411,8 +404,12 @@ const DeckMulti = (props: DeckMultiProps) => {
|
||||
ref={containerRef}
|
||||
viewport={viewport}
|
||||
layers={layers}
|
||||
mapStyle={selectedMap.mapStyle}
|
||||
mapProvider={selectedMap.mapProvider}
|
||||
mapStyle={
|
||||
formData.map_renderer === 'mapbox'
|
||||
? formData.mapbox_style
|
||||
: formData.maplibre_style
|
||||
}
|
||||
mapProvider={formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'}
|
||||
mapboxApiKey={getMapboxApiKey()}
|
||||
setControlValue={setControlValue}
|
||||
onViewportChange={setViewport}
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
FilterState,
|
||||
JsonValue,
|
||||
ContextMenuFilters,
|
||||
getMapProviderMapStyle,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
import {
|
||||
@@ -185,12 +184,6 @@ export function createDeckGLComponent(
|
||||
}, [computeLayers, prevFormData, prevFilterState, prevPayload, props]);
|
||||
|
||||
const { formData, setControlValue, height, width } = props;
|
||||
const selectedMap = getMapProviderMapStyle({
|
||||
mapProvider: formData.map_renderer,
|
||||
maplibreStyle: formData.maplibre_style,
|
||||
mapboxStyle: formData.mapbox_style,
|
||||
legacyMapStyle: formData.map_style,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -198,8 +191,14 @@ export function createDeckGLComponent(
|
||||
ref={containerRef}
|
||||
viewport={viewport}
|
||||
layers={layers}
|
||||
mapStyle={selectedMap.mapStyle}
|
||||
mapProvider={selectedMap.mapProvider}
|
||||
mapStyle={
|
||||
formData.map_renderer === 'mapbox'
|
||||
? formData.mapbox_style
|
||||
: formData.maplibre_style
|
||||
}
|
||||
mapProvider={
|
||||
formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'
|
||||
}
|
||||
mapboxApiKey={getMapboxApiKey()}
|
||||
setControlValue={setControlValue}
|
||||
width={width}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { SqlaFormData } from '@superset-ui/core';
|
||||
import {
|
||||
computeGeoJsonTextOptionsFromJsOutput,
|
||||
computeGeoJsonTextOptionsFromFormData,
|
||||
computeGeoJsonIconOptionsFromJsOutput,
|
||||
computeGeoJsonIconOptionsFromFormData,
|
||||
} from './Geojson';
|
||||
|
||||
jest.mock('react-map-gl/maplibre', () => ({
|
||||
__esModule: true,
|
||||
Map: () => null,
|
||||
useControl: () => null,
|
||||
}));
|
||||
|
||||
test('computeGeoJsonTextOptionsFromJsOutput returns an empty object for non-object input', () => {
|
||||
expect(computeGeoJsonTextOptionsFromJsOutput(null)).toEqual({});
|
||||
expect(computeGeoJsonTextOptionsFromJsOutput(42)).toEqual({});
|
||||
expect(computeGeoJsonTextOptionsFromJsOutput([1, 2, 3])).toEqual({});
|
||||
expect(computeGeoJsonTextOptionsFromJsOutput('string')).toEqual({});
|
||||
});
|
||||
|
||||
test('computeGeoJsonTextOptionsFromJsOutput extracts valid text options from the input object', () => {
|
||||
const input = {
|
||||
getText: 'name',
|
||||
getTextColor: [1, 2, 3, 255],
|
||||
invalidOption: true,
|
||||
};
|
||||
const expectedOutput = {
|
||||
getText: 'name',
|
||||
getTextColor: [1, 2, 3, 255],
|
||||
};
|
||||
expect(computeGeoJsonTextOptionsFromJsOutput(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
test('computeGeoJsonTextOptionsFromFormData computes text options based on form data', () => {
|
||||
const formData: SqlaFormData = {
|
||||
label_property_name: 'name',
|
||||
label_color: { r: 1, g: 2, b: 3, a: 1 },
|
||||
label_size: 123,
|
||||
label_size_unit: 'pixels',
|
||||
datasource: 'test_datasource',
|
||||
viz_type: 'deck_geojson',
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
getText: expect.any(Function),
|
||||
getTextColor: [1, 2, 3, 255],
|
||||
getTextSize: 123,
|
||||
textSizeUnits: 'pixels',
|
||||
};
|
||||
|
||||
const actualOutput = computeGeoJsonTextOptionsFromFormData(formData);
|
||||
expect(actualOutput).toEqual(expectedOutput);
|
||||
|
||||
const sampleFeature = { properties: { name: 'Test' } };
|
||||
expect(actualOutput.getText(sampleFeature)).toBe('Test');
|
||||
});
|
||||
|
||||
test('computeGeoJsonIconOptionsFromJsOutput returns an empty object for non-object input', () => {
|
||||
expect(computeGeoJsonIconOptionsFromJsOutput(null)).toEqual({});
|
||||
expect(computeGeoJsonIconOptionsFromJsOutput(42)).toEqual({});
|
||||
expect(computeGeoJsonIconOptionsFromJsOutput([1, 2, 3])).toEqual({});
|
||||
expect(computeGeoJsonIconOptionsFromJsOutput('string')).toEqual({});
|
||||
});
|
||||
|
||||
test('computeGeoJsonIconOptionsFromJsOutput extracts valid icon options from the input object', () => {
|
||||
const input = {
|
||||
getIcon: 'icon_name',
|
||||
getIconColor: [1, 2, 3, 255],
|
||||
invalidOption: false,
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
getIcon: 'icon_name',
|
||||
getIconColor: [1, 2, 3, 255],
|
||||
};
|
||||
|
||||
expect(computeGeoJsonIconOptionsFromJsOutput(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
test('computeGeoJsonIconOptionsFromFormData computes icon options based on form data', () => {
|
||||
const formData: SqlaFormData = {
|
||||
icon_url: 'https://example.com/icon.png',
|
||||
icon_size: 123,
|
||||
icon_size_unit: 'pixels',
|
||||
datasource: 'test_datasource',
|
||||
viz_type: 'deck_geojson',
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
getIcon: expect.any(Function),
|
||||
getIconSize: 123,
|
||||
iconSizeUnits: 'pixels',
|
||||
};
|
||||
|
||||
const actualOutput = computeGeoJsonIconOptionsFromFormData(formData);
|
||||
expect(actualOutput).toEqual(expectedOutput);
|
||||
|
||||
expect(actualOutput.getIcon()).toEqual({
|
||||
url: 'https://example.com/icon.png',
|
||||
height: 128,
|
||||
width: 128,
|
||||
});
|
||||
});
|
||||
@@ -1,297 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ReactElement } from 'react';
|
||||
import type { ControlPanelSectionConfig } from '@superset-ui/chart-controls';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { render } from '@testing-library/react';
|
||||
import { SqlaFormData } from '@superset-ui/core';
|
||||
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
|
||||
import DeckGLGeoJson, {
|
||||
computeGeoJsonTextOptionsFromJsOutput,
|
||||
computeGeoJsonTextOptionsFromFormData,
|
||||
computeGeoJsonIconOptionsFromJsOutput,
|
||||
computeGeoJsonIconOptionsFromFormData,
|
||||
getPoints,
|
||||
} from './Geojson';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
const mockDeckGLContainerProps: Array<Record<string, unknown>> = [];
|
||||
|
||||
jest.mock('../../DeckGLContainer', () => ({
|
||||
DeckGLContainerStyledWrapper: (props: Record<string, unknown>) => {
|
||||
mockDeckGLContainerProps.push(props);
|
||||
const React = jest.requireActual('react');
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'deckgl-container' },
|
||||
props.children,
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/mapbox', () => ({
|
||||
getMapboxApiKey: () => 'bootstrap-mapbox-key',
|
||||
hasMapboxApiKey: () => true,
|
||||
}));
|
||||
|
||||
jest.mock('react-map-gl/maplibre', () => ({
|
||||
__esModule: true,
|
||||
Map: () => null,
|
||||
useControl: () => null,
|
||||
}));
|
||||
|
||||
test('computeGeoJsonTextOptionsFromJsOutput returns an empty object for non-object input', () => {
|
||||
expect(computeGeoJsonTextOptionsFromJsOutput(null)).toEqual({});
|
||||
expect(computeGeoJsonTextOptionsFromJsOutput(42)).toEqual({});
|
||||
expect(computeGeoJsonTextOptionsFromJsOutput([1, 2, 3])).toEqual({});
|
||||
expect(computeGeoJsonTextOptionsFromJsOutput('string')).toEqual({});
|
||||
});
|
||||
|
||||
test('computeGeoJsonTextOptionsFromJsOutput extracts valid text options from the input object', () => {
|
||||
const input = {
|
||||
getText: 'name',
|
||||
getTextColor: [1, 2, 3, 255],
|
||||
invalidOption: true,
|
||||
};
|
||||
const expectedOutput = {
|
||||
getText: 'name',
|
||||
getTextColor: [1, 2, 3, 255],
|
||||
};
|
||||
expect(computeGeoJsonTextOptionsFromJsOutput(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
test('computeGeoJsonTextOptionsFromFormData computes text options based on form data', () => {
|
||||
const formData: SqlaFormData = {
|
||||
label_property_name: 'name',
|
||||
label_color: { r: 1, g: 2, b: 3, a: 1 },
|
||||
label_size: 123,
|
||||
label_size_unit: 'pixels',
|
||||
datasource: 'test_datasource',
|
||||
viz_type: 'deck_geojson',
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
getText: expect.any(Function),
|
||||
getTextColor: [1, 2, 3, 255],
|
||||
getTextSize: 123,
|
||||
textSizeUnits: 'pixels',
|
||||
};
|
||||
|
||||
const actualOutput = computeGeoJsonTextOptionsFromFormData(formData);
|
||||
expect(actualOutput).toEqual(expectedOutput);
|
||||
|
||||
const sampleFeature = { properties: { name: 'Test' } };
|
||||
expect(actualOutput.getText(sampleFeature)).toBe('Test');
|
||||
});
|
||||
|
||||
test('computeGeoJsonIconOptionsFromJsOutput returns an empty object for non-object input', () => {
|
||||
expect(computeGeoJsonIconOptionsFromJsOutput(null)).toEqual({});
|
||||
expect(computeGeoJsonIconOptionsFromJsOutput(42)).toEqual({});
|
||||
expect(computeGeoJsonIconOptionsFromJsOutput([1, 2, 3])).toEqual({});
|
||||
expect(computeGeoJsonIconOptionsFromJsOutput('string')).toEqual({});
|
||||
});
|
||||
|
||||
test('computeGeoJsonIconOptionsFromJsOutput extracts valid icon options from the input object', () => {
|
||||
const input = {
|
||||
getIcon: 'icon_name',
|
||||
getIconColor: [1, 2, 3, 255],
|
||||
invalidOption: false,
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
getIcon: 'icon_name',
|
||||
getIconColor: [1, 2, 3, 255],
|
||||
};
|
||||
|
||||
expect(computeGeoJsonIconOptionsFromJsOutput(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
test('computeGeoJsonIconOptionsFromFormData computes icon options based on form data', () => {
|
||||
const formData: SqlaFormData = {
|
||||
icon_url: 'https://example.com/icon.png',
|
||||
icon_size: 123,
|
||||
icon_size_unit: 'pixels',
|
||||
datasource: 'test_datasource',
|
||||
viz_type: 'deck_geojson',
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
getIcon: expect.any(Function),
|
||||
getIconSize: 123,
|
||||
iconSizeUnits: 'pixels',
|
||||
};
|
||||
|
||||
const actualOutput = computeGeoJsonIconOptionsFromFormData(formData);
|
||||
expect(actualOutput).toEqual(expectedOutput);
|
||||
|
||||
expect(actualOutput.getIcon()).toEqual({
|
||||
url: 'https://example.com/icon.png',
|
||||
height: 128,
|
||||
width: 128,
|
||||
});
|
||||
});
|
||||
|
||||
test('controlPanel expands Map section so renderer controls are visible', () => {
|
||||
const mapSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section !== null && section.label === 'Map',
|
||||
);
|
||||
|
||||
expect(mapSection).toBeDefined();
|
||||
expect(mapSection?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
test('getPoints skips malformed GeoJSON entries instead of throwing', () => {
|
||||
const features = [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [1, 2] },
|
||||
properties: {},
|
||||
},
|
||||
[[0, 0]],
|
||||
null,
|
||||
] as unknown as Parameters<typeof getPoints>[0];
|
||||
|
||||
expect(getPoints(features)).toEqual([
|
||||
[1, 2],
|
||||
[1, 2],
|
||||
]);
|
||||
expect(getPoints()).toEqual([]);
|
||||
});
|
||||
|
||||
const renderWithTheme = (component: ReactElement) =>
|
||||
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
|
||||
|
||||
const geoJsonProps = {
|
||||
formData: {
|
||||
datasource: 'test_datasource',
|
||||
viz_type: 'deck_geojson',
|
||||
slice_id: 1,
|
||||
autozoom: false,
|
||||
map_style: 'legacy-map-style',
|
||||
extruded: false,
|
||||
filled: true,
|
||||
stroked: true,
|
||||
line_width: 1,
|
||||
line_width_unit: 'pixels',
|
||||
point_radius_scale: 1,
|
||||
enable_labels: false,
|
||||
enable_icons: false,
|
||||
},
|
||||
payload: {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [0, 0] },
|
||||
properties: { name: 'Test point' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
setControlValue: jest.fn(),
|
||||
viewport: { longitude: 0, latitude: 0, zoom: 1 },
|
||||
onAddFilter: jest.fn(),
|
||||
height: 600,
|
||||
width: 800,
|
||||
filterState: {},
|
||||
onContextMenu: jest.fn(),
|
||||
setDataMask: jest.fn(),
|
||||
emitCrossFilters: false,
|
||||
};
|
||||
|
||||
const lastDeckGLContainerProps = () =>
|
||||
mockDeckGLContainerProps
|
||||
.slice()
|
||||
.reverse()
|
||||
.find(props => props?.viewport !== undefined);
|
||||
|
||||
test('DeckGLGeoJson passes selected MapLibre renderer props to the container', () => {
|
||||
mockDeckGLContainerProps.length = 0;
|
||||
|
||||
renderWithTheme(
|
||||
<DeckGLGeoJson
|
||||
{...geoJsonProps}
|
||||
formData={{
|
||||
...geoJsonProps.formData,
|
||||
map_renderer: 'maplibre',
|
||||
maplibre_style: 'https://example.com/maplibre-style.json',
|
||||
mapbox_style: 'mapbox://styles/mapbox/dark-v9',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastDeckGLContainerProps()).toEqual(
|
||||
expect.objectContaining({
|
||||
mapProvider: 'maplibre',
|
||||
mapStyle: 'https://example.com/maplibre-style.json',
|
||||
mapboxApiKey: 'bootstrap-mapbox-key',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('DeckGLGeoJson passes selected Mapbox renderer props to the container', () => {
|
||||
mockDeckGLContainerProps.length = 0;
|
||||
|
||||
renderWithTheme(
|
||||
<DeckGLGeoJson
|
||||
{...geoJsonProps}
|
||||
formData={{
|
||||
...geoJsonProps.formData,
|
||||
map_renderer: 'mapbox',
|
||||
maplibre_style: 'https://example.com/maplibre-style.json',
|
||||
mapbox_style: 'mapbox://styles/mapbox/satellite-v9',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastDeckGLContainerProps()).toEqual(
|
||||
expect.objectContaining({
|
||||
mapProvider: 'mapbox',
|
||||
mapStyle: 'mapbox://styles/mapbox/satellite-v9',
|
||||
mapboxApiKey: 'bootstrap-mapbox-key',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('DeckGLGeoJson falls back to legacy map_style when provider-specific style is absent', () => {
|
||||
mockDeckGLContainerProps.length = 0;
|
||||
|
||||
renderWithTheme(
|
||||
<DeckGLGeoJson
|
||||
{...geoJsonProps}
|
||||
formData={{
|
||||
...geoJsonProps.formData,
|
||||
map_renderer: 'maplibre',
|
||||
maplibre_style: undefined,
|
||||
map_style: 'legacy-map-style',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastDeckGLContainerProps()).toEqual(
|
||||
expect.objectContaining({
|
||||
mapProvider: 'maplibre',
|
||||
mapStyle: 'legacy-map-style',
|
||||
mapboxApiKey: 'bootstrap-mapbox-key',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
QueryFormData,
|
||||
SetDataMaskHook,
|
||||
SqlaFormData,
|
||||
getMapProviderMapStyle,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
import {
|
||||
@@ -47,7 +46,6 @@ import { Point } from '../../types';
|
||||
import { GetLayerType } from '../../factory';
|
||||
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
||||
import { BLACK_COLOR, PRIMARY_COLOR } from '../../utilities/controls';
|
||||
import { getMapboxApiKey } from '../../utils/mapbox';
|
||||
|
||||
type ProcessedFeature = Feature<Geometry, GeoJsonProperties> & {
|
||||
properties: JsonObject;
|
||||
@@ -359,19 +357,9 @@ export type DeckGLGeoJsonProps = {
|
||||
emitCrossFilters?: boolean;
|
||||
};
|
||||
|
||||
export function getPoints(data?: Point[]) {
|
||||
if (!Array.isArray(data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getPoints(data: Point[]) {
|
||||
return data.reduce((acc: Array<any>, feature: any) => {
|
||||
let bounds;
|
||||
try {
|
||||
bounds = geojsonExtent(feature);
|
||||
} catch {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const bounds = geojsonExtent(feature);
|
||||
if (bounds) {
|
||||
return [...acc, [bounds[0], bounds[1]], [bounds[2], bounds[3]]];
|
||||
}
|
||||
@@ -394,13 +382,13 @@ const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
|
||||
|
||||
const viewport: Viewport = useMemo(() => {
|
||||
if (formData.autozoom) {
|
||||
const points = getPoints(payload?.data?.features);
|
||||
const points = getPoints(payload.data.features) || [];
|
||||
|
||||
if (points.length) {
|
||||
return fitViewport(props.viewport, {
|
||||
width,
|
||||
height,
|
||||
points,
|
||||
points: getPoints(payload.data.features) || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -424,21 +412,12 @@ const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
|
||||
emitCrossFilters: props.emitCrossFilters,
|
||||
});
|
||||
|
||||
const selectedMap = getMapProviderMapStyle({
|
||||
mapProvider: formData.map_renderer,
|
||||
maplibreStyle: formData.maplibre_style,
|
||||
mapboxStyle: formData.mapbox_style,
|
||||
legacyMapStyle: formData.map_style,
|
||||
});
|
||||
|
||||
return (
|
||||
<DeckGLContainerStyledWrapper
|
||||
ref={containerRef}
|
||||
viewport={viewport}
|
||||
layers={[layer]}
|
||||
mapProvider={selectedMap.mapProvider}
|
||||
mapStyle={selectedMap.mapStyle}
|
||||
mapboxApiKey={getMapboxApiKey()}
|
||||
mapStyle={formData.map_style}
|
||||
setControlValue={setControlValue}
|
||||
height={height}
|
||||
width={width}
|
||||
|
||||
@@ -82,7 +82,6 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
{
|
||||
label: t('Map'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[mapProvider],
|
||||
[mapboxStyle],
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ReactElement } from 'react';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { render, screen } from '@testing-library/react';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
@@ -34,23 +33,10 @@ const mockGetColorBreakpointsBuckets = jest.spyOn(
|
||||
);
|
||||
|
||||
// Mock DeckGL container and Legend
|
||||
const mockDeckGLContainerProps: Array<Record<string, unknown>> = [];
|
||||
|
||||
jest.mock('../../DeckGLContainer', () => ({
|
||||
DeckGLContainerStyledWrapper: (props: Record<string, unknown>) => {
|
||||
mockDeckGLContainerProps.push(props);
|
||||
const React = jest.requireActual('react');
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'deckgl-container' },
|
||||
props.children,
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/mapbox', () => ({
|
||||
getMapboxApiKey: () => 'bootstrap-mapbox-key',
|
||||
hasMapboxApiKey: () => true,
|
||||
DeckGLContainerStyledWrapper: ({ children }: any) => (
|
||||
<div data-testid="deckgl-container">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../components/Legend', () => ({ categories, position }: any) => (
|
||||
@@ -123,95 +109,6 @@ const mockProps = {
|
||||
emitCrossFilters: false,
|
||||
};
|
||||
|
||||
describe('DeckGLPolygon renderer propagation', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetBuckets.mockReturnValue({});
|
||||
mockGetColorBreakpointsBuckets.mockReturnValue({});
|
||||
});
|
||||
|
||||
const renderWithTheme = (component: ReactElement) =>
|
||||
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
|
||||
|
||||
const lastDeckGLContainerProps = () =>
|
||||
mockDeckGLContainerProps
|
||||
.slice()
|
||||
.reverse()
|
||||
.find(props => props?.viewport !== undefined);
|
||||
|
||||
test('passes selected MapLibre renderer props to the container', () => {
|
||||
mockDeckGLContainerProps.length = 0;
|
||||
|
||||
renderWithTheme(
|
||||
<DeckGLPolygon
|
||||
{...mockProps}
|
||||
formData={{
|
||||
...mockProps.formData,
|
||||
map_renderer: 'maplibre',
|
||||
maplibre_style: 'https://example.com/polygon-maplibre-style.json',
|
||||
mapbox_style: 'mapbox://styles/mapbox/dark-v9',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastDeckGLContainerProps()).toEqual(
|
||||
expect.objectContaining({
|
||||
mapProvider: 'maplibre',
|
||||
mapStyle: 'https://example.com/polygon-maplibre-style.json',
|
||||
mapboxApiKey: 'bootstrap-mapbox-key',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('passes selected Mapbox renderer props to the container', () => {
|
||||
mockDeckGLContainerProps.length = 0;
|
||||
|
||||
renderWithTheme(
|
||||
<DeckGLPolygon
|
||||
{...mockProps}
|
||||
formData={{
|
||||
...mockProps.formData,
|
||||
map_renderer: 'mapbox',
|
||||
maplibre_style: 'https://example.com/polygon-maplibre-style.json',
|
||||
mapbox_style: 'mapbox://styles/mapbox/satellite-v9',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastDeckGLContainerProps()).toEqual(
|
||||
expect.objectContaining({
|
||||
mapProvider: 'mapbox',
|
||||
mapStyle: 'mapbox://styles/mapbox/satellite-v9',
|
||||
mapboxApiKey: 'bootstrap-mapbox-key',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to legacy map_style when provider-specific style is absent', () => {
|
||||
mockDeckGLContainerProps.length = 0;
|
||||
|
||||
renderWithTheme(
|
||||
<DeckGLPolygon
|
||||
{...mockProps}
|
||||
formData={{
|
||||
...mockProps.formData,
|
||||
map_renderer: 'maplibre',
|
||||
maplibre_style: undefined,
|
||||
map_style: 'legacy-map-style',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastDeckGLContainerProps()).toEqual(
|
||||
expect.objectContaining({
|
||||
mapProvider: 'maplibre',
|
||||
mapStyle: 'legacy-map-style',
|
||||
mapboxApiKey: 'bootstrap-mapbox-key',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeckGLPolygon bucket generation logic', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -222,7 +119,7 @@ describe('DeckGLPolygon bucket generation logic', () => {
|
||||
mockGetColorBreakpointsBuckets.mockReturnValue({});
|
||||
});
|
||||
|
||||
const renderWithTheme = (component: ReactElement) =>
|
||||
const renderWithTheme = (component: React.ReactElement) =>
|
||||
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
|
||||
|
||||
test('should use getBuckets for linear_palette color scheme', () => {
|
||||
@@ -330,7 +227,7 @@ describe('DeckGLPolygon Error Handling and Edge Cases', () => {
|
||||
mockGetColorBreakpointsBuckets.mockReturnValue({});
|
||||
});
|
||||
|
||||
const renderWithTheme = (component: ReactElement) =>
|
||||
const renderWithTheme = (component: React.ReactElement) =>
|
||||
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
|
||||
|
||||
test('handles empty features data gracefully', () => {
|
||||
@@ -394,7 +291,7 @@ describe('DeckGLPolygon Legend Integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const renderWithTheme = (component: ReactElement) =>
|
||||
const renderWithTheme = (component: React.ReactElement) =>
|
||||
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
|
||||
|
||||
test('renders legend with non-empty categories when metric and linear_palette are defined', () => {
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
JsonValue,
|
||||
QueryFormData,
|
||||
SetDataMaskHook,
|
||||
getMapProviderMapStyle,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
import { PolygonLayer } from '@deck.gl/layers';
|
||||
@@ -58,7 +57,6 @@ import { TooltipProps } from '../../components/Tooltip';
|
||||
import { GetLayerType } from '../../factory';
|
||||
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
||||
import { DEFAULT_DECKGL_COLOR } from '../../utilities/Shared_DeckGL';
|
||||
import { getMapboxApiKey } from '../../utils/mapbox';
|
||||
import {
|
||||
createTooltipContent,
|
||||
CommonTooltipRows,
|
||||
@@ -341,12 +339,6 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
|
||||
colorSchemeType === COLOR_SCHEME_TYPES.color_breakpoints
|
||||
? getColorBreakpointsBuckets(formData.color_breakpoints)
|
||||
: getBuckets(formData, payload.data.features, accessor);
|
||||
const selectedMap = getMapProviderMapStyle({
|
||||
mapProvider: formData.map_renderer,
|
||||
maplibreStyle: formData.maplibre_style,
|
||||
mapboxStyle: formData.mapbox_style,
|
||||
legacyMapStyle: formData.map_style,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -355,9 +347,7 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
|
||||
viewport={viewport}
|
||||
layers={getLayers()}
|
||||
setControlValue={setControlValue}
|
||||
mapProvider={selectedMap.mapProvider}
|
||||
mapStyle={selectedMap.mapStyle}
|
||||
mapboxApiKey={getMapboxApiKey()}
|
||||
mapStyle={formData.map_style}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
/>
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { ControlPanelState } from '@superset-ui/chart-controls';
|
||||
import { OSM_TILE_STYLE_URL } from '@superset-ui/core/utils/mapStyles';
|
||||
import { mapProvider, maplibreStyle } from './Shared_DeckGL';
|
||||
|
||||
const setBootstrap = ({
|
||||
conf = {},
|
||||
deckglTiles,
|
||||
}: {
|
||||
conf?: Record<string, unknown>;
|
||||
deckglTiles?: unknown;
|
||||
}) => {
|
||||
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
|
||||
common: {
|
||||
conf,
|
||||
...(deckglTiles === undefined ? {} : { deckgl_tiles: deckglTiles }),
|
||||
},
|
||||
})}'></div>`;
|
||||
};
|
||||
|
||||
type MapProviderControlConfig = typeof mapProvider.config & {
|
||||
mapStateToProps: (state: ControlPanelState) => {
|
||||
options?: unknown;
|
||||
warning?: string;
|
||||
default?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const getMapProviderProps = (value?: string) =>
|
||||
(mapProvider.config as MapProviderControlConfig).mapStateToProps({
|
||||
form_data: { map_renderer: value },
|
||||
} as unknown as ControlPanelState);
|
||||
|
||||
type MapLibreStyleControlConfig = typeof maplibreStyle.config & {
|
||||
mapStateToProps: () => {
|
||||
choices: unknown;
|
||||
default: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const getMapLibreStyleProps = () =>
|
||||
(maplibreStyle.config as MapLibreStyleControlConfig).mapStateToProps();
|
||||
|
||||
test('deck.gl MapLibre style choices expose Streets (OSM)', () => {
|
||||
expect(maplibreStyle.config.choices).toContainEqual([
|
||||
OSM_TILE_STYLE_URL,
|
||||
'Streets (OSM)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('deck.gl map renderer hides Mapbox when no key exists for new selections', () => {
|
||||
setBootstrap({ conf: {} });
|
||||
|
||||
const props = getMapProviderProps('maplibre');
|
||||
|
||||
expect(props.options).toEqual([
|
||||
{ value: 'maplibre', label: 'MapLibre (open-source)' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('deck.gl map renderer keeps saved Mapbox visible while disabled without a key', () => {
|
||||
setBootstrap({ conf: {} });
|
||||
|
||||
const props = getMapProviderProps('mapbox');
|
||||
|
||||
expect(props.options).toContainEqual({
|
||||
value: 'mapbox',
|
||||
label: 'Mapbox (API key required)',
|
||||
disabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('deck.gl map renderer enables Mapbox when a key exists', () => {
|
||||
setBootstrap({ conf: { MAPBOX_API_KEY: 'pk.test' } });
|
||||
|
||||
const props = getMapProviderProps('maplibre');
|
||||
|
||||
expect(props.options).toEqual([
|
||||
{ value: 'maplibre', label: 'MapLibre (open-source)' },
|
||||
{ value: 'mapbox', label: 'Mapbox (API key required)' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('deck.gl map renderer keeps the original explanatory description', () => {
|
||||
expect(mapProvider.config.description).toBe(
|
||||
'Select the map tile provider. MapLibre is open-source and requires no API key. Mapbox requires MAPBOX_API_KEY to be configured in Superset.',
|
||||
);
|
||||
});
|
||||
|
||||
test('deck.gl map renderer defaults to configured Mapbox when a key exists', () => {
|
||||
setBootstrap({
|
||||
conf: { DEFAULT_MAP_RENDERER: 'mapbox', MAPBOX_API_KEY: 'pk.test' },
|
||||
});
|
||||
|
||||
expect(getMapProviderProps('maplibre').default).toBe('mapbox');
|
||||
});
|
||||
|
||||
test('deck.gl map renderer falls back from configured Mapbox default without a key', () => {
|
||||
setBootstrap({ conf: { DEFAULT_MAP_RENDERER: 'mapbox' } });
|
||||
|
||||
expect(getMapProviderProps('maplibre').default).toBe('maplibre');
|
||||
});
|
||||
|
||||
test('deck.gl map style falls back to default tiles for empty overrides', () => {
|
||||
setBootstrap({ deckglTiles: [] });
|
||||
|
||||
const props = getMapLibreStyleProps();
|
||||
|
||||
expect(props.choices).toContainEqual([OSM_TILE_STYLE_URL, 'Streets (OSM)']);
|
||||
expect(props.default).toBe(
|
||||
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||
);
|
||||
});
|
||||
|
||||
test('deck.gl map style falls back to default tiles for malformed overrides', () => {
|
||||
setBootstrap({
|
||||
deckglTiles: [
|
||||
['https://tiles.example.com/{z}/{x}/{y}.png'],
|
||||
['https://tiles.example.com/{z}/{x}/{y}.png', 'Custom', 'Extra'],
|
||||
['', 'Empty URL'],
|
||||
],
|
||||
});
|
||||
|
||||
const props = getMapLibreStyleProps();
|
||||
|
||||
expect(props.choices).toContainEqual([OSM_TILE_STYLE_URL, 'Streets (OSM)']);
|
||||
expect(props.default).toBe(
|
||||
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||
);
|
||||
});
|
||||
|
||||
test('deck.gl map style accepts well-formed tile overrides', () => {
|
||||
setBootstrap({
|
||||
deckglTiles: [['https://tiles.example.com/style.json', 'Custom']],
|
||||
});
|
||||
|
||||
const props = getMapLibreStyleProps();
|
||||
|
||||
expect(props.choices).toEqual([
|
||||
['https://tiles.example.com/style.json', 'Custom'],
|
||||
]);
|
||||
expect(props.default).toBe('https://tiles.example.com/style.json');
|
||||
});
|
||||
@@ -25,20 +25,9 @@ import {
|
||||
getCategoricalSchemeRegistry,
|
||||
getSequentialSchemeRegistry,
|
||||
SequentialScheme,
|
||||
type QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
getDefaultMapRenderer,
|
||||
getBootstrapDataFromDocument,
|
||||
getMapRendererOptions,
|
||||
OSM_TILE_STYLE_URL,
|
||||
type MapRendererOption,
|
||||
type MapProvider,
|
||||
} from '@superset-ui/core/utils/mapStyles';
|
||||
import {
|
||||
ControlPanelState,
|
||||
ControlStateMapping,
|
||||
ControlState,
|
||||
CustomControlItem,
|
||||
D3_FORMAT_OPTIONS,
|
||||
getColorControlsProps,
|
||||
@@ -51,23 +40,15 @@ import {
|
||||
isColorSchemeTypeVisible,
|
||||
} from './utils';
|
||||
import { TooltipTemplateControl } from './TooltipTemplateControl';
|
||||
import { hasMapboxApiKey } from '../utils/mapbox';
|
||||
|
||||
const categoricalSchemeRegistry = getCategoricalSchemeRegistry();
|
||||
const sequentialSchemeRegistry = getSequentialSchemeRegistry();
|
||||
|
||||
export const DEFAULT_DECKGL_COLOR = { r: 158, g: 158, b: 158, a: 1 };
|
||||
|
||||
type DeckGLTileChoice = [string, string];
|
||||
type MapStyleVisibilityProps = {
|
||||
controls?: ControlStateMapping;
|
||||
};
|
||||
type MetricControlValue = {
|
||||
type?: unknown;
|
||||
value?: unknown;
|
||||
};
|
||||
let deckglTiles: string[][];
|
||||
|
||||
export const DEFAULT_DECKGL_TILES: DeckGLTileChoice[] = [
|
||||
export const DEFAULT_DECKGL_TILES = [
|
||||
[
|
||||
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||
'Light (Carto)',
|
||||
@@ -81,10 +62,9 @@ export const DEFAULT_DECKGL_TILES: DeckGLTileChoice[] = [
|
||||
'Streets (Carto)',
|
||||
],
|
||||
['https://tiles.openfreemap.org/styles/liberty', 'Liberty (OpenFreeMap)'],
|
||||
[OSM_TILE_STYLE_URL, 'Streets (OSM)'],
|
||||
];
|
||||
|
||||
export const DEFAULT_MAPBOX_TILES: DeckGLTileChoice[] = [
|
||||
export const DEFAULT_MAPBOX_TILES = [
|
||||
['mapbox://styles/mapbox/streets-v9', 'Streets (Mapbox)'],
|
||||
['mapbox://styles/mapbox/dark-v9', 'Dark (Mapbox)'],
|
||||
['mapbox://styles/mapbox/light-v9', 'Light (Mapbox)'],
|
||||
@@ -93,56 +73,17 @@ export const DEFAULT_MAPBOX_TILES: DeckGLTileChoice[] = [
|
||||
['mapbox://styles/mapbox/outdoors-v9', 'Outdoors (Mapbox)'],
|
||||
];
|
||||
|
||||
const isDeckGLTileChoices = (value: unknown): value is DeckGLTileChoice[] =>
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every(
|
||||
choice =>
|
||||
Array.isArray(choice) &&
|
||||
choice.length === 2 &&
|
||||
typeof choice[0] === 'string' &&
|
||||
choice[0].trim().length > 0 &&
|
||||
typeof choice[1] === 'string' &&
|
||||
choice[1].trim().length > 0,
|
||||
);
|
||||
|
||||
const getDeckGLTiles = () => {
|
||||
const bootstrapData = getBootstrapDataFromDocument();
|
||||
const deckglTilesOverride = (
|
||||
bootstrapData as {
|
||||
common?: { deckgl_tiles?: unknown };
|
||||
} | null
|
||||
)?.common?.deckgl_tiles;
|
||||
return isDeckGLTileChoices(deckglTilesOverride)
|
||||
? deckglTilesOverride
|
||||
: DEFAULT_DECKGL_TILES;
|
||||
if (!deckglTiles) {
|
||||
const appContainer = document.getElementById('app');
|
||||
const { common } = JSON.parse(
|
||||
appContainer?.getAttribute('data-bootstrap') || '{}',
|
||||
);
|
||||
deckglTiles = common?.deckgl_tiles ?? DEFAULT_DECKGL_TILES;
|
||||
}
|
||||
return deckglTiles;
|
||||
};
|
||||
|
||||
const getMapLibreStyleProps = () => {
|
||||
const choices = getDeckGLTiles();
|
||||
return {
|
||||
choices,
|
||||
default: choices[0][0],
|
||||
};
|
||||
};
|
||||
|
||||
const getLabeledMapRendererOptions = ({
|
||||
hasMapboxKey,
|
||||
currentValue,
|
||||
}: {
|
||||
hasMapboxKey: boolean;
|
||||
currentValue?: MapProvider;
|
||||
}) =>
|
||||
getMapRendererOptions({ hasMapboxKey, currentValue }).map(
|
||||
(option: MapRendererOption) => ({
|
||||
...option,
|
||||
label:
|
||||
option.value === 'maplibre'
|
||||
? t('MapLibre (open-source)')
|
||||
: t('Mapbox (API key required)'),
|
||||
}),
|
||||
);
|
||||
|
||||
const DEFAULT_VIEWPORT = {
|
||||
longitude: 6.85236157047845,
|
||||
latitude: 31.222656842808707,
|
||||
@@ -515,26 +456,15 @@ export const mapProvider = {
|
||||
label: t('Map Renderer'),
|
||||
clearable: false,
|
||||
renderTrigger: true,
|
||||
options: getLabeledMapRendererOptions({
|
||||
hasMapboxKey: hasMapboxApiKey(),
|
||||
}),
|
||||
choices: [
|
||||
['maplibre', t('MapLibre (open-source)')],
|
||||
['mapbox', t('Mapbox (API key required)')],
|
||||
],
|
||||
default: 'maplibre',
|
||||
description: t(
|
||||
'Select the map tile provider. MapLibre is open-source and requires no API key. ' +
|
||||
'Mapbox requires MAPBOX_API_KEY to be configured in Superset.',
|
||||
),
|
||||
mapStateToProps: (state: ControlPanelState) => {
|
||||
const hasKey = hasMapboxApiKey();
|
||||
return {
|
||||
options: getLabeledMapRendererOptions({
|
||||
hasMapboxKey: hasKey,
|
||||
currentValue: state.form_data?.map_renderer as
|
||||
| MapProvider
|
||||
| undefined,
|
||||
}),
|
||||
default: getDefaultMapRenderer(),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -546,14 +476,13 @@ export const maplibreStyle = {
|
||||
clearable: false,
|
||||
renderTrigger: true,
|
||||
freeForm: true,
|
||||
choices: DEFAULT_DECKGL_TILES,
|
||||
default: DEFAULT_DECKGL_TILES[0][0],
|
||||
choices: getDeckGLTiles(),
|
||||
default: getDeckGLTiles()[0][0],
|
||||
description: t(
|
||||
'Base layer map style. Accepts a MapLibre-compatible style URL.',
|
||||
),
|
||||
visibility: ({ controls }: MapStyleVisibilityProps) =>
|
||||
visibility: ({ controls }: ControlPanelState) =>
|
||||
controls?.map_renderer?.value !== 'mapbox',
|
||||
mapStateToProps: getMapLibreStyleProps,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -570,7 +499,7 @@ export const mapboxStyle = {
|
||||
description: t(
|
||||
'Base layer map style. Accepts a Mapbox style URL (mapbox://styles/...).',
|
||||
),
|
||||
visibility: ({ controls }: MapStyleVisibilityProps) =>
|
||||
visibility: ({ controls }: ControlPanelState) =>
|
||||
controls?.map_renderer?.value === 'mapbox',
|
||||
},
|
||||
};
|
||||
@@ -588,14 +517,14 @@ export const geojsonColumn = {
|
||||
},
|
||||
};
|
||||
|
||||
const extractMetricsFromFormData = (formData: QueryFormData) => {
|
||||
const metrics = new Set<unknown>();
|
||||
const extractMetricsFromFormData = (formData: any) => {
|
||||
const metrics = new Set<string>();
|
||||
|
||||
if (formData.metrics) {
|
||||
(Array.isArray(formData.metrics)
|
||||
? formData.metrics
|
||||
: [formData.metrics]
|
||||
).forEach((metric: unknown) => metrics.add(metric));
|
||||
).forEach((metric: any) => metrics.add(metric));
|
||||
}
|
||||
|
||||
if (formData.point_radius_fixed?.value) {
|
||||
@@ -604,9 +533,8 @@ const extractMetricsFromFormData = (formData: QueryFormData) => {
|
||||
|
||||
Object.entries(formData).forEach(([, value]) => {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
const controlValue = value as MetricControlValue;
|
||||
if (controlValue.type === 'metric' && controlValue.value) {
|
||||
metrics.add(controlValue.value);
|
||||
if ((value as any).type === 'metric' && (value as any).value) {
|
||||
metrics.add((value as any).value);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -627,7 +555,7 @@ export const tooltipContents = {
|
||||
),
|
||||
ghostButtonText: t('Drop columns/metrics here or click'),
|
||||
disabledTabs: new Set(['saved', 'sqlExpression']),
|
||||
mapStateToProps: (state: ControlPanelState) => {
|
||||
mapStateToProps: (state: any) => {
|
||||
const { datasource, form_data: formData } = state;
|
||||
|
||||
const selectedMetrics = formData
|
||||
@@ -636,8 +564,7 @@ export const tooltipContents = {
|
||||
|
||||
return {
|
||||
columns: datasource?.columns || [],
|
||||
savedMetrics:
|
||||
datasource && 'metrics' in datasource ? datasource.metrics || [] : [],
|
||||
savedMetrics: datasource?.metrics || [],
|
||||
datasource,
|
||||
selectedMetrics,
|
||||
disabledTabs: new Set(['saved', 'sqlExpression']),
|
||||
@@ -657,7 +584,7 @@ export const tooltipTemplate = {
|
||||
default: '',
|
||||
description: '',
|
||||
placeholder: '',
|
||||
mapStateToProps: (_state: ControlPanelState, control: ControlState) => ({
|
||||
mapStateToProps: (_state: any, control: any) => ({
|
||||
value: control.value,
|
||||
}),
|
||||
},
|
||||
@@ -775,13 +702,8 @@ export const deckGLBreakpointMetric: CustomControlItem = {
|
||||
// mapStateToProps: (state: ControlPanelState) => ({
|
||||
// datasource: state.datasource,
|
||||
// }),
|
||||
visibility: ({ controls }: MapStyleVisibilityProps) =>
|
||||
controls
|
||||
? isColorSchemeTypeVisible(
|
||||
controls,
|
||||
COLOR_SCHEME_TYPES.color_breakpoints,
|
||||
)
|
||||
: false,
|
||||
visibility: ({ controls }: { controls: any }) =>
|
||||
isColorSchemeTypeVisible(controls, COLOR_SCHEME_TYPES.color_breakpoints),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { getMapboxApiKey, hasMapboxApiKey } from './mapbox';
|
||||
|
||||
const setBootstrap = (conf: Record<string, unknown>) => {
|
||||
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
|
||||
common: { conf },
|
||||
})}'></div>`;
|
||||
};
|
||||
|
||||
test('deck.gl Mapbox helpers read key presence from bootstrap data', () => {
|
||||
setBootstrap({ MAPBOX_API_KEY: 'pk.test' });
|
||||
|
||||
expect(getMapboxApiKey()).toBe('pk.test');
|
||||
expect(hasMapboxApiKey()).toBe(true);
|
||||
|
||||
setBootstrap({});
|
||||
|
||||
expect(getMapboxApiKey()).toBe('');
|
||||
expect(hasMapboxApiKey()).toBe(false);
|
||||
});
|
||||
@@ -17,15 +17,19 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
getMapboxApiKeyFromBootstrap,
|
||||
hasMapboxApiKey as hasBootstrapMapboxApiKey,
|
||||
} from '@superset-ui/core/utils/mapStyles';
|
||||
|
||||
export function getMapboxApiKey(): string {
|
||||
return getMapboxApiKeyFromBootstrap();
|
||||
}
|
||||
|
||||
export function hasMapboxApiKey(): boolean {
|
||||
return hasBootstrapMapboxApiKey();
|
||||
if (typeof document === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const appContainer = document.getElementById('app');
|
||||
const dataBootstrap = appContainer?.getAttribute('data-bootstrap');
|
||||
if (dataBootstrap) {
|
||||
const bootstrapData = JSON.parse(dataBootstrap);
|
||||
return bootstrapData?.common?.conf?.MAPBOX_API_KEY || '';
|
||||
}
|
||||
} catch {
|
||||
// If bootstrap data is unavailable or malformed, return empty string
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Meta, StoryFn } from '@storybook/react-webpack5';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Card, Col, Layout, Row } from '@superset-ui/core/components';
|
||||
import { ErrorAlert } from './ErrorAlert';
|
||||
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -44,14 +44,14 @@ const options: { [key in string]: RowCountLabelProps } = {
|
||||
export const RowCountLabelGallery = () => (
|
||||
<>
|
||||
{Object.keys(options).map(name => (
|
||||
<div key={name}>
|
||||
<>
|
||||
<h4>{name}</h4>
|
||||
<RowCountLabel
|
||||
loading={options[name].loading}
|
||||
rowcount={options[name].rowcount}
|
||||
limit={options[name].limit}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { Tag } from 'src/components/Tag';
|
||||
import type { CheckableTagProps } from 'src/components/Tag';
|
||||
import type { TagType } from 'src/types/TagType';
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { TagsList, type TagsListProps } from 'src/components/TagsList';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -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() }),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -902,150 +902,3 @@ test('Clear All on a required filter disables Apply via validateStatus', async (
|
||||
expect(screen.getByTestId(getTestId('apply-button'))).toBeDisabled();
|
||||
updateDataMaskSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('FilterBar renders the configured filter name in the bar', async () => {
|
||||
const filterId = 'NATIVE_FILTER-name-render';
|
||||
const filter = createFilter({
|
||||
id: filterId,
|
||||
name: 'Region',
|
||||
filterType: 'filter_select',
|
||||
targets: [{ datasetId: 7, column: { name: 'region' } }],
|
||||
chartsInScope: [18],
|
||||
});
|
||||
const state = {
|
||||
...stateWithoutNativeFilters,
|
||||
dashboardInfo: {
|
||||
id: 1,
|
||||
dash_edit_perm: true,
|
||||
filterBarOrientation: FilterBarOrientation.Vertical,
|
||||
metadata: {
|
||||
native_filter_configuration: [filter],
|
||||
chart_configuration: {},
|
||||
},
|
||||
},
|
||||
dashboardState: {
|
||||
...stateWithoutNativeFilters.dashboardState,
|
||||
activeTabs: ['ROOT_ID'],
|
||||
},
|
||||
dataMask: { [filterId]: createDataMask(filterId, undefined, {}) },
|
||||
nativeFilters: {
|
||||
filters: { [filterId]: filter },
|
||||
filtersState: {},
|
||||
},
|
||||
};
|
||||
|
||||
const props = createOpenedBarProps();
|
||||
renderFilterBar(props, state);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Region')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Clicking the gear "Add or edit filters and controls" item opens the FiltersConfigModal', async () => {
|
||||
const props = createOpenedBarProps();
|
||||
renderFilterBar(props, stateWithoutNativeFilters);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
const gear = await screen.findByTestId('filterbar-orientation-icon');
|
||||
userEvent.click(gear);
|
||||
|
||||
const addEditItem = await screen.findByText(
|
||||
'Add or edit filters and controls',
|
||||
);
|
||||
userEvent.click(addEditItem);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -844,7 +844,9 @@ test('enables save button and includes updated title when editing an existing di
|
||||
jest.useRealTimers();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: SAVE_REGEX })).not.toBeDisabled(),
|
||||
expect(
|
||||
screen.getByRole('button', { name: SAVE_REGEX }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
@@ -908,7 +910,9 @@ test('enables save button and includes updated title when editing an existing ch
|
||||
jest.useRealTimers();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: SAVE_REGEX })).not.toBeDisabled(),
|
||||
expect(
|
||||
screen.getByRole('button', { name: SAVE_REGEX }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
@@ -956,144 +960,3 @@ test('empty state disappears when a filter is added via dropdown', async () => {
|
||||
});
|
||||
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('restores a deleted filter via the "Restore filter" button', async () => {
|
||||
const nativeFilterConfig = [
|
||||
buildNativeFilter('NATIVE_FILTER-1', 'state', []),
|
||||
buildNativeFilter('NATIVE_FILTER-2', 'country', []),
|
||||
];
|
||||
const state = {
|
||||
...defaultState(),
|
||||
dashboardInfo: {
|
||||
metadata: { native_filter_configuration: nativeFilterConfig },
|
||||
},
|
||||
dashboardLayout,
|
||||
};
|
||||
|
||||
defaultRender(state, { ...props, createNewOnOpen: false });
|
||||
|
||||
const filterContainer = screen.getByTestId('filter-title-container');
|
||||
const firstTab = within(filterContainer).getAllByRole('tab')[0];
|
||||
fireEvent.click(within(firstTab).getByRole('button', { name: /delete/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/you have removed this filter/i),
|
||||
).toBeInTheDocument();
|
||||
const restoreButton = screen.getByTestId('restore-filter-button');
|
||||
await userEvent.click(restoreButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/you have removed this filter/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('textbox', { name: FILTER_NAME_REGEX })).toHaveValue(
|
||||
'state',
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
test('undoes a filter deletion via the sidebar "Undo?" link', async () => {
|
||||
const nativeFilterConfig = [
|
||||
buildNativeFilter('NATIVE_FILTER-1', 'state', []),
|
||||
buildNativeFilter('NATIVE_FILTER-2', 'country', []),
|
||||
];
|
||||
const state = {
|
||||
...defaultState(),
|
||||
dashboardInfo: {
|
||||
metadata: { native_filter_configuration: nativeFilterConfig },
|
||||
},
|
||||
dashboardLayout,
|
||||
};
|
||||
|
||||
defaultRender(state, { ...props, createNewOnOpen: false });
|
||||
|
||||
const filterContainer = screen.getByTestId('filter-title-container');
|
||||
const firstTab = within(filterContainer).getAllByRole('tab')[0];
|
||||
fireEvent.click(within(firstTab).getByRole('button', { name: /delete/i }));
|
||||
|
||||
const undoButton = await screen.findByTestId('undo-button');
|
||||
expect(undoButton).toHaveTextContent(/undo\?/i);
|
||||
await userEvent.click(undoButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/you have removed this filter/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('textbox', { name: FILTER_NAME_REGEX })).toHaveValue(
|
||||
'state',
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
test('shows info tooltips beside value-filter options and reveals tooltip text on hover', async () => {
|
||||
defaultRender();
|
||||
|
||||
// Upstream Cypress checked six tooltips on the value filter (nativeFilterTooltips
|
||||
// 0..5); asserting the count keeps this test honest if tooltips get added or
|
||||
// removed alongside a regression to the option list.
|
||||
const tooltipIcons = screen.getAllByLabelText(/show info tooltip/i);
|
||||
expect(tooltipIcons.length).toBeGreaterThanOrEqual(6);
|
||||
|
||||
await userEvent.hover(tooltipIcons[0]);
|
||||
|
||||
// role='tooltip' trips an nwsapi bug on antd's internal :only-child selectors;
|
||||
// query the portal node by class and require non-empty text content so an empty
|
||||
// tooltip render does not pass.
|
||||
await waitFor(() => {
|
||||
const tooltip = document.querySelector('.ant-tooltip-inner');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
expect(tooltip?.textContent?.trim()).toBeTruthy();
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
test('numerical range filter — Range Type selector lets the user pick a display mode', async () => {
|
||||
defaultRender();
|
||||
|
||||
await userEvent.click(screen.getByText(VALUE_REGEX));
|
||||
await userEvent.click(await screen.findByText(NUMERICAL_RANGE_REGEX));
|
||||
|
||||
const rangeTypeCombobox = await screen.findByRole('combobox', {
|
||||
name: /range type/i,
|
||||
});
|
||||
|
||||
// Default render is "Slider and range input"; asserting Slider is absent first
|
||||
// ensures the post-click assertion proves a state change rather than passing on
|
||||
// the default selection.
|
||||
expect(
|
||||
document.querySelector('.ant-select-selection-item[title="Slider"]'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(rangeTypeCombobox);
|
||||
const sliderOption = await screen.findByRole('option', {
|
||||
name: /^slider$/i,
|
||||
});
|
||||
await userEvent.click(sliderOption);
|
||||
|
||||
// antd Select renders the active selection as a span whose title attribute is
|
||||
// the picked option's label.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('.ant-select-selection-item[title="Slider"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
test('toggles "Filter has default value" to show and hide the Default Value control', async () => {
|
||||
defaultRender();
|
||||
|
||||
const defaultValueCheckbox = getCheckbox(DEFAULT_VALUE_REGEX);
|
||||
expect(defaultValueCheckbox).not.toBeChecked();
|
||||
expect(screen.queryByText(/^default value$/i)).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(defaultValueCheckbox);
|
||||
|
||||
expect(defaultValueCheckbox).toBeChecked();
|
||||
expect(await screen.findByText(/^default value$/i)).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(defaultValueCheckbox);
|
||||
|
||||
expect(defaultValueCheckbox).not.toBeChecked();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/^default value$/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,14 +84,6 @@ test('the form validates required fields', async () => {
|
||||
expect(onSave).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
async function openDropdownAndAddFilter(
|
||||
getByTestId: (id: string) => HTMLElement,
|
||||
findByRole: (role: string, opts: { name: RegExp }) => Promise<HTMLElement>,
|
||||
) {
|
||||
fireEvent.mouseEnter(getByTestId('new-item-dropdown-button'));
|
||||
fireEvent.click(await findByRole('menuitem', { name: /add filter/i }));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('createNewOnOpen', () => {
|
||||
test('does not show alert when there is no unsaved filters', async () => {
|
||||
@@ -107,23 +99,15 @@ describe('createNewOnOpen', () => {
|
||||
onCancel,
|
||||
createNewOnOpen: false,
|
||||
});
|
||||
await openDropdownAndAddFilter(getByTestId, findByRole);
|
||||
const dropdownButton = getByTestId('new-item-dropdown-button');
|
||||
fireEvent.mouseEnter(dropdownButton);
|
||||
const addFilterMenuItem = await findByRole('menuitem', {
|
||||
name: /add filter/i,
|
||||
});
|
||||
fireEvent.click(addFilterMenuItem);
|
||||
fireEvent.click(getByRole('button', { name: 'Cancel' }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(0);
|
||||
expect(getByRole('alert')).toBeInTheDocument();
|
||||
expect(getByRole('alert')).toHaveTextContent('There are unsaved changes.');
|
||||
});
|
||||
|
||||
test('confirm-cancel button proceeds with cancel after the unsaved alert', async () => {
|
||||
const onCancel = jest.fn();
|
||||
const { getByRole, getByTestId, findByRole } = setup({
|
||||
onCancel,
|
||||
createNewOnOpen: false,
|
||||
});
|
||||
await openDropdownAndAddFilter(getByTestId, findByRole);
|
||||
fireEvent.click(getByRole('button', { name: 'Cancel' }));
|
||||
expect(getByRole('alert')).toBeInTheDocument();
|
||||
fireEvent.click(getByTestId('native-filter-modal-confirm-cancel-button'));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,21 +41,11 @@ jest.mock('react-redux', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: ['TAB_1'] },
|
||||
dashboardLayout: {
|
||||
present: {
|
||||
'CHART-123': {
|
||||
type: 'CHART',
|
||||
meta: { chartId: 123 },
|
||||
parents: ['ROOT_ID', 'TAB_1'],
|
||||
},
|
||||
TAB_1: { type: 'TAB', id: 'TAB_1' },
|
||||
},
|
||||
},
|
||||
};
|
||||
return selector(mockState);
|
||||
(useSelector as jest.Mock).mockImplementation(selector => {
|
||||
if (selector.name === 'useActiveDashboardTabs') {
|
||||
return ['TAB_1'];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,14 +99,6 @@ test('useIsFilterInScope should return false for filters with inactive rootPath'
|
||||
});
|
||||
|
||||
test('useSelectFiltersInScope should return all filters in scope when no tabs exist', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: [] },
|
||||
dashboardLayout: { present: {} },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
id: 'filter_1',
|
||||
@@ -619,479 +601,3 @@ test('useChartCustomizationConfiguration ignores undefined items in metadata', (
|
||||
expect.objectContaining({ id: 'CHART_CUSTOMIZATION-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
// --- Embedded / hideTab: activeTabs is empty ---
|
||||
// When an embedded dashboard uses hideTab:true, the Tabs component never
|
||||
// mounts, so setActiveTab never fires and activeTabs stays []. The same
|
||||
// empty state occurs transiently on first render of any tabbed dashboard.
|
||||
//
|
||||
// useActiveDashboardTabs derives the default first tab from the layout when
|
||||
// Redux activeTabs is empty, so scope evaluation uses the correct default
|
||||
// tab instead of either "no tabs active" (blank filter bar) or "all tabs"
|
||||
// (showing out-of-scope filters).
|
||||
|
||||
// Helper: build a layout with ROOT_ID → TABS container → TAB children
|
||||
function embeddedLayout(extras: Record<string, Record<string, unknown>> = {}) {
|
||||
return {
|
||||
ROOT_ID: {
|
||||
type: 'ROOT',
|
||||
id: 'ROOT_ID',
|
||||
children: ['TABS-1'],
|
||||
},
|
||||
'TABS-1': {
|
||||
type: 'TABS',
|
||||
id: 'TABS-1',
|
||||
children: ['TAB-Company', 'TAB-Desktop'],
|
||||
},
|
||||
'TAB-Company': {
|
||||
type: 'TAB',
|
||||
id: 'TAB-Company',
|
||||
children: ['CHART-1', 'CHART-2'],
|
||||
},
|
||||
'TAB-Desktop': {
|
||||
type: 'TAB',
|
||||
id: 'TAB-Desktop',
|
||||
children: ['CHART-3'],
|
||||
},
|
||||
'CHART-1': {
|
||||
type: 'CHART',
|
||||
meta: { chartId: 1 },
|
||||
parents: ['ROOT_ID', 'TAB-Company'],
|
||||
},
|
||||
'CHART-2': {
|
||||
type: 'CHART',
|
||||
meta: { chartId: 2 },
|
||||
parents: ['ROOT_ID', 'TAB-Company'],
|
||||
},
|
||||
'CHART-3': {
|
||||
type: 'CHART',
|
||||
meta: { chartId: 3 },
|
||||
parents: ['ROOT_ID', 'TAB-Desktop'],
|
||||
},
|
||||
...extras,
|
||||
};
|
||||
}
|
||||
|
||||
test('useIsFilterInScope: filter scoped to default tab is in-scope when activeTabs is empty', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: [] },
|
||||
dashboardLayout: { present: embeddedLayout() },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filter: Filter = {
|
||||
id: 'filter_default_tab',
|
||||
name: 'Default Tab Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [1, 2],
|
||||
scope: { rootPath: ['TAB-Company'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
|
||||
description: 'Filter scoped to default (first) tab',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(filter)).toBe(true);
|
||||
});
|
||||
|
||||
test('useIsFilterInScope: filter scoped only to non-default tab is out-of-scope when activeTabs is empty', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: [] },
|
||||
dashboardLayout: { present: embeddedLayout() },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filter: Filter = {
|
||||
id: 'filter_other_tab',
|
||||
name: 'Other Tab Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [3],
|
||||
scope: { rootPath: ['TAB-Desktop'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
|
||||
description: 'Filter scoped only to non-default tab',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(filter)).toBe(false);
|
||||
});
|
||||
|
||||
test('useIsFilterInScope: filter with rootPath to default tab is in-scope when activeTabs is empty', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: [] },
|
||||
dashboardLayout: { present: embeddedLayout() },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filter: Filter = {
|
||||
id: 'filter_rootpath_default',
|
||||
name: 'RootPath Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
scope: { rootPath: ['TAB-Company'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
|
||||
description: 'Filter using rootPath to default tab',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(filter)).toBe(true);
|
||||
});
|
||||
|
||||
test('useSelectFiltersInScope: only default-tab filters are in scope when activeTabs is empty (embedded hideTab)', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: [] },
|
||||
dashboardLayout: { present: embeddedLayout() },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
id: 'filter_company',
|
||||
name: 'Company Tab Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [1, 2],
|
||||
scope: { rootPath: ['TAB-Company'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'survey_rating' }, datasetId: 1 }],
|
||||
description: 'Filter scoped to default tab',
|
||||
},
|
||||
{
|
||||
id: 'filter_desktop_only',
|
||||
name: 'Desktop Only Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [3],
|
||||
scope: { rootPath: ['TAB-Desktop'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'pool_name' }, datasetId: 2 }],
|
||||
description: 'Filter scoped only to non-default tab',
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useSelectFiltersInScope(filters));
|
||||
expect(result.current[0]).toHaveLength(1);
|
||||
expect(result.current[0][0]).toEqual(
|
||||
expect.objectContaining({ id: 'filter_company' }),
|
||||
);
|
||||
expect(result.current[1]).toHaveLength(1);
|
||||
expect(result.current[1][0]).toEqual(
|
||||
expect.objectContaining({ id: 'filter_desktop_only' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('useSelectFiltersInScope: dividers are always in scope even when activeTabs is empty', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: [] },
|
||||
dashboardLayout: { present: embeddedLayout() },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const items: (Filter | Divider)[] = [
|
||||
{
|
||||
id: 'divider_embedded',
|
||||
type: NativeFilterType.Divider,
|
||||
title: 'Section',
|
||||
description: 'Divider in embedded mode',
|
||||
},
|
||||
{
|
||||
id: 'filter_default',
|
||||
name: 'Default Tab Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [1],
|
||||
scope: { rootPath: ['TAB-Company'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'col' }, datasetId: 1 }],
|
||||
description: 'Filter in default tab',
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useSelectFiltersInScope(items));
|
||||
expect(result.current[0]).toHaveLength(2);
|
||||
expect(result.current[1]).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('useSelectFiltersInScope: correctly scopes chartsInScope filters when activeTabs is populated', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: ['TAB-Company'] },
|
||||
dashboardLayout: { present: embeddedLayout() },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
id: 'filter_active_tab',
|
||||
name: 'Active Tab Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [1],
|
||||
scope: { rootPath: ['TAB-Company'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'col' }, datasetId: 1 }],
|
||||
description: 'Filter scoped to active tab',
|
||||
},
|
||||
{
|
||||
id: 'filter_inactive_tab',
|
||||
name: 'Inactive Tab Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [3],
|
||||
scope: { rootPath: ['TAB-Desktop'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'col' }, datasetId: 2 }],
|
||||
description: 'Filter scoped to inactive tab',
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useSelectFiltersInScope(filters));
|
||||
expect(result.current[0]).toHaveLength(1);
|
||||
expect(result.current[0][0]).toEqual(
|
||||
expect.objectContaining({ id: 'filter_active_tab' }),
|
||||
);
|
||||
expect(result.current[1]).toHaveLength(1);
|
||||
expect(result.current[1][0]).toEqual(
|
||||
expect.objectContaining({ id: 'filter_inactive_tab' }),
|
||||
);
|
||||
});
|
||||
|
||||
// Layout-derived default-tab fallback edge cases. These pin behavior of
|
||||
// useActiveDashboardTabs when activeTabs is empty across structural variants
|
||||
// of dashboardLayout, so the fallback can't silently regress.
|
||||
|
||||
test('useIsFilterInScope: dashboard with no top-level tabs (root child is GRID_ID) treats filters as in-scope', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: [] },
|
||||
dashboardLayout: {
|
||||
present: {
|
||||
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
|
||||
GRID_ID: { type: 'GRID', id: 'GRID_ID', children: ['CHART-1'] },
|
||||
'CHART-1': {
|
||||
type: 'CHART',
|
||||
meta: { chartId: 1 },
|
||||
parents: ['ROOT_ID', 'GRID_ID'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filter: Filter = {
|
||||
id: 'filter_no_tabs',
|
||||
name: 'No-tabs filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [1],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'col' }, datasetId: 1 }],
|
||||
description: 'Filter on a no-tabs dashboard',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
// Chart has no TAB ancestors → tabParents is empty → considered in-scope.
|
||||
expect(result.current(filter)).toBe(true);
|
||||
});
|
||||
|
||||
test('useIsFilterInScope: missing dashboardLayout falls back without crashing', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: [] },
|
||||
dashboardLayout: { present: undefined },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filter: Filter = {
|
||||
id: 'filter_no_layout',
|
||||
name: 'No layout',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [1],
|
||||
scope: { rootPath: ['TAB-A'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'col' }, datasetId: 1 }],
|
||||
description: 'Filter when layout is missing',
|
||||
};
|
||||
|
||||
// The hook should not throw when layout is missing. Without the
|
||||
// useActiveDashboardTabs guard, indexing dashboardLayout[ROOT_ID] would
|
||||
// crash here.
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(() => result.current(filter)).not.toThrow();
|
||||
});
|
||||
|
||||
// Shared fixture for the two nested-tabs tests below. Layout is identical;
|
||||
// only the redux activeTabs differs (empty for the default-path test,
|
||||
// inner-only for the hideTab ancestor-merge test).
|
||||
const nestedTabsLayout = () => ({
|
||||
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['TABS-1'] },
|
||||
'TABS-1': {
|
||||
type: 'TABS',
|
||||
id: 'TABS-1',
|
||||
children: ['TAB-Outer1', 'TAB-Outer2'],
|
||||
},
|
||||
'TAB-Outer1': {
|
||||
type: 'TAB',
|
||||
id: 'TAB-Outer1',
|
||||
children: ['TABS-2'],
|
||||
},
|
||||
'TAB-Outer2': {
|
||||
type: 'TAB',
|
||||
id: 'TAB-Outer2',
|
||||
children: ['CHART-Outer2'],
|
||||
},
|
||||
'TABS-2': {
|
||||
type: 'TABS',
|
||||
id: 'TABS-2',
|
||||
children: ['TAB-Inner1', 'TAB-Inner2'],
|
||||
},
|
||||
'TAB-Inner1': {
|
||||
type: 'TAB',
|
||||
id: 'TAB-Inner1',
|
||||
children: ['CHART-Inner1'],
|
||||
},
|
||||
'TAB-Inner2': {
|
||||
type: 'TAB',
|
||||
id: 'TAB-Inner2',
|
||||
children: ['CHART-Inner2'],
|
||||
},
|
||||
'CHART-Inner1': {
|
||||
type: 'CHART',
|
||||
meta: { chartId: 11 },
|
||||
parents: ['ROOT_ID', 'TAB-Outer1', 'TABS-2', 'TAB-Inner1'],
|
||||
},
|
||||
'CHART-Inner2': {
|
||||
type: 'CHART',
|
||||
meta: { chartId: 12 },
|
||||
parents: ['ROOT_ID', 'TAB-Outer1', 'TABS-2', 'TAB-Inner2'],
|
||||
},
|
||||
'CHART-Outer2': {
|
||||
type: 'CHART',
|
||||
meta: { chartId: 20 },
|
||||
parents: ['ROOT_ID', 'TAB-Outer2'],
|
||||
},
|
||||
});
|
||||
|
||||
const mockNestedTabsState = (activeTabs: string[]) => ({
|
||||
dashboardState: { activeTabs },
|
||||
dashboardLayout: { present: nestedTabsLayout() },
|
||||
});
|
||||
|
||||
test('useIsFilterInScope: deeply nested tabs — default path includes inner-tab default', () => {
|
||||
// ROOT → TABS-1 → [TAB-Outer1, TAB-Outer2]
|
||||
// └─ TAB-Outer1 → TABS-2 → [TAB-Inner1, TAB-Inner2]
|
||||
// Default path should be ['TAB-Outer1', 'TAB-Inner1'].
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) =>
|
||||
selector(mockNestedTabsState([])),
|
||||
);
|
||||
|
||||
const innerDefaultFilter: Filter = {
|
||||
id: 'filter_inner1',
|
||||
name: 'Inner default-tab filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [11],
|
||||
scope: { rootPath: ['TAB-Inner1'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'col' }, datasetId: 1 }],
|
||||
description: 'Filter scoped to inner default tab',
|
||||
};
|
||||
|
||||
const innerNonDefaultFilter: Filter = {
|
||||
...innerDefaultFilter,
|
||||
id: 'filter_inner2',
|
||||
chartsInScope: [12],
|
||||
scope: { rootPath: ['TAB-Inner2'], excluded: [] },
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
// CHART-Inner1's tab parents [TAB-Outer1, TAB-Inner1] are both in default
|
||||
// path → in scope.
|
||||
expect(result.current(innerDefaultFilter)).toBe(true);
|
||||
// CHART-Inner2's tab parent TAB-Inner2 is not in default path → out of scope.
|
||||
expect(result.current(innerNonDefaultFilter)).toBe(false);
|
||||
});
|
||||
|
||||
test('useIsFilterInScope: nested Tabs mounted under hideTab:true — outer ancestor merged so outer-tab scoping is preserved', () => {
|
||||
// hideTab:true skips the top-level Tabs but a nested Tabs can still mount
|
||||
// and dispatch setActiveTab. activeTabs holds only the inner id; without
|
||||
// ancestor merging, filters whose charts have tabParents=[outer, inner]
|
||||
// would be marked out-of-scope because the outer id is missing.
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) =>
|
||||
selector(mockNestedTabsState(['TAB-Inner1'])),
|
||||
);
|
||||
|
||||
const innerActiveFilter: Filter = {
|
||||
id: 'filter_inner1_active',
|
||||
name: 'Filter scoped to active inner tab',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [11],
|
||||
scope: { rootPath: ['TAB-Inner1'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'col' }, datasetId: 1 }],
|
||||
description: 'Filter on the active inner tab',
|
||||
};
|
||||
|
||||
const otherOuterFilter: Filter = {
|
||||
id: 'filter_other_outer',
|
||||
name: 'Filter scoped to non-default outer tab',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [20],
|
||||
scope: { rootPath: ['TAB-Outer2'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'col' }, datasetId: 2 }],
|
||||
description: 'Filter on the other outer tab — must stay out of scope',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
// Outer ancestor TAB-Outer1 is merged into the active path → in scope.
|
||||
expect(result.current(innerActiveFilter)).toBe(true);
|
||||
// TAB-Outer2 is not in the active path → out of scope, scoping preserved.
|
||||
expect(result.current(otherOuterFilter)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -30,8 +30,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { FilterElement } from './FilterBar/FilterControls/types';
|
||||
import { ActiveTabs, DashboardLayout, RootState } from '../../types';
|
||||
import { CHART_TYPE, TAB_TYPE, TABS_TYPE } from '../../util/componentTypes';
|
||||
import { DASHBOARD_ROOT_ID } from '../../util/constants';
|
||||
import { CHART_TYPE, TAB_TYPE } from '../../util/componentTypes';
|
||||
import { isChartCustomizationId } from './FiltersConfigModal/utils';
|
||||
import {
|
||||
migrateChartCustomizationArray,
|
||||
@@ -39,7 +38,6 @@ import {
|
||||
} from '../../util/migrateChartCustomization';
|
||||
|
||||
const EMPTY_ARRAY: ChartCustomizationConfiguration = [];
|
||||
const EMPTY_ACTIVE_TABS: ActiveTabs = [];
|
||||
const defaultFilterConfiguration: (Filter | Divider)[] = [];
|
||||
|
||||
export const selectFilterConfiguration: (
|
||||
@@ -177,83 +175,22 @@ export function useDashboardHasTabs() {
|
||||
const dashboardLayout = useDashboardLayout();
|
||||
return useMemo(
|
||||
() =>
|
||||
dashboardLayout
|
||||
? Object.values(dashboardLayout).some(
|
||||
element => element.type === TAB_TYPE,
|
||||
)
|
||||
: false,
|
||||
Object.values(dashboardLayout).some(element => element.type === TAB_TYPE),
|
||||
[dashboardLayout],
|
||||
);
|
||||
}
|
||||
|
||||
function useActiveDashboardTabs(): ActiveTabs {
|
||||
const reduxTabs = useSelector<RootState, ActiveTabs>(
|
||||
function useActiveDashboardTabs() {
|
||||
return useSelector<RootState, ActiveTabs>(
|
||||
state => state.dashboardState?.activeTabs,
|
||||
);
|
||||
const dashboardLayout = useDashboardLayout();
|
||||
|
||||
return useMemo(() => {
|
||||
const reduxList = reduxTabs ?? [];
|
||||
const reduxFallback = reduxList.length ? reduxList : EMPTY_ACTIVE_TABS;
|
||||
if (!dashboardLayout) return reduxFallback;
|
||||
|
||||
// Tabbed dashboards always nest the top-level TABS container as the first
|
||||
// child of ROOT. If that invariant doesn't hold (no-tabs layout), no
|
||||
// fallback applies and we use reduxTabs as-is.
|
||||
const root = dashboardLayout[DASHBOARD_ROOT_ID];
|
||||
if (!root?.children?.length) return reduxFallback;
|
||||
const topContainer = dashboardLayout[root.children[0]];
|
||||
if (topContainer?.type !== TABS_TYPE || !topContainer.children?.length) {
|
||||
return reduxFallback;
|
||||
}
|
||||
|
||||
// Walk every TABS container along the active path. For each container,
|
||||
// pick the child Redux marked active; otherwise pick the first child (the
|
||||
// default the live Tabs component would render). This handles:
|
||||
// - empty reduxTabs (hideTab:true, no permalink) → full default path
|
||||
// - reduxTabs missing an outer ancestor (hideTab:true skipped the
|
||||
// top-level Tabs, but a nested Tabs dispatched setActiveTab) → fill
|
||||
// in the missing ancestor so outer-tab scoping is preserved
|
||||
// - fully populated reduxTabs (normal hydration) → same result
|
||||
const reduxSet = new Set(reduxList);
|
||||
const result: ActiveTabs = [];
|
||||
const queue: string[] = [
|
||||
topContainer.children.find(c => reduxSet.has(c)) ??
|
||||
topContainer.children[0],
|
||||
];
|
||||
while (queue.length > 0) {
|
||||
const tabId = queue.shift()!;
|
||||
result.push(tabId);
|
||||
const tab = dashboardLayout[tabId];
|
||||
if (!tab?.children) continue;
|
||||
for (const childId of tab.children) {
|
||||
const child = dashboardLayout[childId];
|
||||
if (child?.type !== TABS_TYPE || !child.children?.length) continue;
|
||||
queue.push(
|
||||
child.children.find(c => reduxSet.has(c)) ?? child.children[0],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve any reduxTabs entries that fell outside the traversed path so
|
||||
// we never silently drop a redux-marked active tab id.
|
||||
const resultSet = new Set(result);
|
||||
for (const id of reduxList) {
|
||||
if (!resultSet.has(id)) result.push(id);
|
||||
}
|
||||
return result;
|
||||
}, [reduxTabs, dashboardLayout]);
|
||||
}
|
||||
|
||||
function useSelectChartTabParents() {
|
||||
const dashboardLayout = useDashboardLayout();
|
||||
const layoutChartItems = useMemo(
|
||||
() =>
|
||||
dashboardLayout
|
||||
? Object.values(dashboardLayout).filter(
|
||||
item => item.type === CHART_TYPE,
|
||||
)
|
||||
: [],
|
||||
Object.values(dashboardLayout).filter(item => item.type === CHART_TYPE),
|
||||
[dashboardLayout],
|
||||
);
|
||||
return useCallback(
|
||||
@@ -262,7 +199,7 @@ function useSelectChartTabParents() {
|
||||
layoutItem => layoutItem.meta?.chartId === chartId,
|
||||
);
|
||||
return chartLayoutItem?.parents?.filter(
|
||||
(parent: string) => dashboardLayout?.[parent]?.type === TAB_TYPE,
|
||||
(parent: string) => dashboardLayout[parent]?.type === TAB_TYPE,
|
||||
);
|
||||
},
|
||||
[dashboardLayout, layoutChartItems],
|
||||
@@ -395,7 +332,6 @@ export function useSelectCustomizationsInScope(
|
||||
| ChartCustomizationDivider
|
||||
)[] = [];
|
||||
|
||||
// we check customization scopes only on dashboards with tabs
|
||||
if (!dashboardHasTabs) {
|
||||
customizationsInScope = customizations;
|
||||
} else {
|
||||
|
||||
@@ -42,10 +42,10 @@ const options: {
|
||||
export const ControlHeaderGallery = () => (
|
||||
<>
|
||||
{Object.entries(options).map(([name, props]) => (
|
||||
<div key={name}>
|
||||
<>
|
||||
<h4>{name}</h4>
|
||||
<ControlHeader {...props} />
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
import * as supersetCore from '@apache-superset/core';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
@@ -52,12 +52,20 @@ declare global {
|
||||
const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
const userId = useSelector<RootState, number | undefined>(
|
||||
({ user }) => user.userId,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId == null) return;
|
||||
if (initialized) return;
|
||||
|
||||
if (!userId) {
|
||||
// No user logged in — nothing to initialize
|
||||
setInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Provide the implementations for @apache-superset/core
|
||||
window.superset = {
|
||||
@@ -72,10 +80,19 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
views,
|
||||
};
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
ExtensionsLoader.getInstance().initializeExtensions();
|
||||
}
|
||||
}, [userId]);
|
||||
const setup = async () => {
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
await ExtensionsLoader.getInstance().initializeExtensions();
|
||||
}
|
||||
setInitialized(true);
|
||||
};
|
||||
|
||||
setup();
|
||||
}, [initialized, userId]);
|
||||
|
||||
if (!initialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user