Compare commits

...

40 Commits

Author SHA1 Message Date
yousoph
ea3ba12ebf Update transformProps.ts 2025-07-17 14:45:06 -07:00
yousoph
268d8e8fee update test 2025-07-17 13:53:09 -07:00
yousoph
ae71b837ff update tests 2025-07-17 13:48:59 -07:00
yousoph
12dd4a712e Update controlPanel.tsx 2025-07-17 10:22:32 -07:00
yousoph
04714b83c2 Update controlPanel.tsx 2025-07-17 00:07:57 -07:00
yousoph
6070e84944 Update transformProps.ts 2025-07-17 00:07:09 -07:00
yousoph
1cd939e612 Update transformProps.ts 2025-07-16 23:52:00 -07:00
yousoph
e71a19a19e Update types.ts 2025-07-16 23:39:10 -07:00
yousoph
e50e378d8b Update controlPanel.tsx 2025-07-16 23:31:23 -07:00
JUST.in DO IT
96cb6030c8 fix(explore): Display missing dataset for denied access (#34129) 2025-07-16 13:36:03 -07:00
Jun Yoneyama
94d47113ea feat(snowflake): Support Snowflake private keys w/o passphrase (#34156) 2025-07-16 20:53:41 +02:00
Mehmet Salih Yavuz
f756cee01b fix(theming): Remove leftover antd5 prefix (#34188) 2025-07-16 19:31:14 +03:00
Cesc Bausà
e8926f177d feat(i18n): add Catalan (ca) translations (#33953) 2025-07-16 16:38:51 +02:00
Mehmet Salih Yavuz
16f4516903 chore(Oracle): Update oracle column length to 128 (#34179) 2025-07-16 14:58:19 +03:00
Beto Dealmeida
000d353ef3 fix(sqllab): database ID (#34181) 2025-07-15 19:37:24 -04:00
Beto Dealmeida
83b6f672ff fix(databricks): string escaper (#34180) 2025-07-15 19:27:55 -04:00
Beto Dealmeida
0dc48e9b41 fix(sqllab): pass DB id instead of name (#33955) 2025-07-15 18:43:34 -04:00
Maxime Beauchemin
fe9eef9198 feat: removing dup logic in sqla/models.py and models/helpers.py (#34177) 2025-07-15 14:02:57 -07:00
Hari Kiran
8a8248b575 docs(development): Fix typo in the documentation (#34163) 2025-07-15 14:30:32 -03:00
Gabriel Torres Ruiz
42d9a78777 feat(theming): Introduce bootstrap-driven Superset theme configurations (#34144) 2025-07-15 09:43:08 -07:00
Mehmet Salih Yavuz
31a15c5162 fix(DrillBy): make drill by work with multi metric charts (#34171) 2025-07-15 15:25:48 +02:00
SBIN2010
67b21c45df feat(filter panel): hide filter panel on all dashboard by default. (#32870) 2025-07-15 10:47:47 +03:00
SBIN2010
b280ab9e1f fix: adding and removing tags does not work in control panel properties modal (#34147) 2025-07-14 23:20:00 +03:00
Maxime Beauchemin
c42be77c25 feat(i18n): load language pack asynchronously (#34119) 2025-07-14 10:59:29 -07:00
Maxime Beauchemin
160917eae8 fix: frontend translation framework crashes on string errors (#34118) 2025-07-14 10:23:18 -07:00
Beto Dealmeida
68b84acd93 feat: improve Doris catalog support (#34140) 2025-07-14 12:01:08 -04:00
ongdisheng
0aa48b6564 fix(dataset): trigger onChange when switching to physical dataset to clear SQL (#34153) 2025-07-14 14:30:05 +03:00
Mehmet Salih Yavuz
0fc1955049 chore(Tags): Sort tags by name if possible (#34149) 2025-07-14 11:20:01 +03:00
ongdisheng
8a704d293b docs: remove duplicated line in Running tests with act section (#34145) 2025-07-12 15:25:11 -07:00
Đỗ Trọng Hải
f4754641c8 build(dev-deps): clean up deprecated Babel proposal plugins (#34125) 2025-07-12 07:07:56 +07:00
dependabot[bot]
7c98c3f4f6 chore(deps): bump flask-cors from 4.0.2 to 6.0.0 (#34138)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-11 13:38:13 -07:00
Beto Dealmeida
5a32777dd0 chore: remove unnecessary disables (#34139) 2025-07-11 11:25:02 -04:00
Damian Pendrak
7229e1ccf3 feat(deckgl): add new color controls with color breakpoints (#34017) 2025-07-11 17:29:26 +03:00
Enzo Martellucci
30695d75d7 fix(DatabaseModal): Resolve Connect button issue for SQLAlchemy URI database connections (#34112) 2025-07-11 14:03:36 +03:00
Vitor Avila
75ee4edc6a fix: Apply metric d3format when currency config is {} for table charts (#34127) 2025-07-10 22:44:22 -03:00
dependabot[bot]
d269e3d187 chore(deps): bump react-json-tree from 0.17.0 to 0.20.0 in /superset-frontend (#33990)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2025-07-10 11:54:59 -07:00
aikawa-ohno
7d0fabe1ab fix(i18n): Update Japanese translations (#33974) 2025-07-10 12:35:44 +03:00
Evan Rusackas
9695249976 fix(screenshots): Change default for SCREENSHOT_PLAYWRIGHT_WAIT_EVENT to domcontentloaded (#34114) 2025-07-10 12:33:40 +03:00
dependabot[bot]
17c1a37afb chore(deps): bump react-error-boundary from 5.0.0 to 6.0.0 in /superset-frontend (#33486)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2025-07-10 10:38:46 +03:00
Maxime Beauchemin
73dfe57ae2 fix: make flask-cors a core dependency (#34115) 2025-07-09 14:54:39 -07:00
144 changed files with 21327 additions and 4228 deletions

View File

@@ -24,6 +24,12 @@ jobs:
submodules: recursive
fetch-depth: 1
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
if: steps.check.outputs.python
uses: ./.github/actions/setup-backend/
@@ -33,10 +39,20 @@ jobs:
run: ./scripts/uv-pip-compile.sh
- name: Check for uncommitted changes
if: steps.check.outputs.python
run: |
if [[ -n "$(git diff)" ]]; then
echo "Full diff (for logging/debugging):"
git diff
echo "Filtered diff (excluding comments and whitespace):"
filtered_diff=$(git diff -U0 | grep '^[-+]' | grep -vE '^[-+]{3}' | grep -vE '^[-+][[:space:]]*#' | grep -vE '^[-+][[:space:]]*$' || true)
echo "$filtered_diff"
if [[ -n "$filtered_diff" ]]; then
echo
echo "ERROR: The pinned dependencies are not up-to-date."
echo "Please run './scripts/uv-pip-compile.sh' and commit the changes."
echo "More info: https://github.com/apache/superset/tree/master/requirements"
exit 1
else
echo "Pinned dependencies are up-to-date."

2
.gitignore vendored
View File

@@ -80,7 +80,7 @@ yarn-error.log
*.min.js
test-changelog.md
*.tsbuildinfo
.venv
# Ignore package-lock in packages
plugins/*/package-lock.json
packages/*/package-lock.json

View File

@@ -24,7 +24,6 @@ assists people when migrating to a new version.
## Next
- [33603](https://github.com/apache/superset/pull/33603) OpenStreetView has been promoted as the new default for Deck.gl visualization since it can be enabled by default without requiring an API key. If you have Mapbox set up and want to disable OpenStreeView in your environment, please follow the steps documented here [https://superset.apache.org/docs/configuration/map-tiles].
- [33116](https://github.com/apache/superset/pull/33116) In Echarts Series charts (e.g. Line, Area, Bar, etc.) charts, the `x_axis_sort_series` and `x_axis_sort_series_ascending` form data items have been renamed with `x_axis_sort` and `x_axis_sort_asc`.
There's a migration added that can potentially affect a significant number of existing charts.
- [32317](https://github.com/apache/superset/pull/32317) The horizontal filter bar feature is now out of testing/beta development and its feature flag `HORIZONTAL_FILTER_BAR` has been removed.
@@ -56,7 +55,6 @@ assists people when migrating to a new version.
- [30284](https://github.com/apache/superset/pull/30284) Deprecated GLOBAL_ASYNC_QUERIES_REDIS_CONFIG in favor of the new GLOBAL_ASYNC_QUERIES_CACHE_BACKEND configuration. To leverage Redis Sentinel, set CACHE_TYPE to RedisSentinelCache, or use RedisCache for standalone Redis
- [31961](https://github.com/apache/superset/pull/31961) Upgraded React from version 16.13.1 to 17.0.2. If you are using custom frontend extensions or plugins, you may need to update them to be compatible with React 17.
- [31260](https://github.com/apache/superset/pull/31260) Docker images now use `uv pip install` instead of `pip install` to manage the python envrionment. Most docker-based deployments will be affected, whether you derive one of the published images, or have custom bootstrap script that install python libraries (drivers)
- [32432](https://github.com/apache/superset/pull/31260) Moves the List Roles FAB view to the frontend and requires `FAB_ADD_SECURITY_API` to be enabled in the configuration and `superset init` to be executed.
### Potential Downtime

View File

@@ -1,78 +0,0 @@
---
title: Map Tiles
sidebar_position: 12
version: 1
---
# Map tiles
Superset uses OSM and Mapbox tiles by default. OSM is free but you still need setting your MAPBOX_API_KEY if you want to use mapbox maps.
## Setting map tiles
Map tiles can be set with `DECKGL_BASE_MAP` in your `superset_config.py` or `superset_config_docker.py`
For adding your own map tiles, you can use the following format.
```python
DECKGL_BASE_MAP = [
['tile://https://your_personal_url/{z}/{x}/{y}.png', 'MyTile']
]
```
Openstreetmap tiles url can be added without prefix.
```python
DECKGL_BASE_MAP = [
['https://c.tile.openstreetmap.org/{z}/{x}/{y}.png', 'OpenStreetMap']
]
```
Default values are:
```python
DECKGL_BASE_MAP = [
['https://tile.openstreetmap.org/{z}/{x}/{y}.png', 'Streets (OSM)'],
['https://tile.osm.ch/osm-swiss-style/{z}/{x}/{y}.png', 'Topography (OSM)'],
['mapbox://styles/mapbox/streets-v9', 'Streets'],
['mapbox://styles/mapbox/dark-v9', 'Dark'],
['mapbox://styles/mapbox/light-v9', 'Light'],
['mapbox://styles/mapbox/satellite-streets-v9', 'Satellite Streets'],
['mapbox://styles/mapbox/satellite-v9', 'Satellite'],
['mapbox://styles/mapbox/outdoors-v9', 'Outdoors'],
]
```
It is possible to set only mapbox by removing osm tiles and other way around.
:::warning
Setting `DECKGL_BASE_MAP` overwrite default values
:::
After defining your map tiles, set them in these variables:
- `CORS_OPTIONS`
- `connect-src` of `TALISMAN_CONFIG` and `TALISMAN_CONFIG_DEV` variables.
```python
ENABLE_CORS = True
CORS_OPTIONS: dict[Any, Any] = {
"origins": [
"https://tile.openstreetmap.org",
"https://tile.osm.ch",
"https://your_personal_url/{z}/{x}/{y}.png",
]
}
.
.
TALISMAN_CONFIG = {
"content_security_policy": {
...
"connect-src": [
"'self'",
"https://api.mapbox.com",
"https://events.mapbox.com",
"https://tile.openstreetmap.org",
"https://tile.osm.ch",
"https://your_personal_url/{z}/{x}/{y}.png",
],
...
}
```

View File

@@ -8,11 +8,11 @@ version: 1
## CORS
To configure CORS, or cross-origin resource sharing, the following dependency must be installed:
```python
pip install apache_superset[cors]
```
:::note
In Superset versions prior to `5.x` you have to install to install `flask-cors` with `pip install flask-cors` to enable CORS support.
:::
The following keys in `superset_config.py` can be specified to configure CORS:

View File

@@ -78,7 +78,7 @@ Affecting the Docker build process:
- **SUPERSET_BUILD_TARGET (default=dev):** which --target to build, either `lean` or `dev` are commonly used
- **INCLUDE_FIREFOX (default=false):** whether to include the Firefox headless browser in the build
- **INCLUDE_CHROMIUM (default=false):** whether to include the Firefox headless browser in the build
- **INCLUDE_CHROMIUM (default=false):** whether to include the Chromium headless browser in the build
- **BUILD_TRANSLATIONS(default=false):** whether to compile the translations from the .po files available
- **SUPERSET_LOAD_EXAMPLES (default=yes):** whether to load the examples into the database upon startup,
save some precious time on startup by `SUPERSET_LOAD_EXAMPLES=no docker compose up`
@@ -614,9 +614,6 @@ act --job test-python-38 --secret GITHUB_TOKEN=$GITHUB_TOKEN --event pull_reques
There is also a utility script included in the Superset codebase to run Python integration tests. The [readme can be found here](https://github.com/apache/superset/tree/master/scripts/tests).
There is also a utility script included in the Superset codebase to run python integration tests. The [readme can be
found here](https://github.com/apache/superset/tree/master/scripts/tests)
To run all integration tests, for example, run this script from the root directory:
```bash

View File

@@ -40,6 +40,7 @@ dependencies = [
"click>=8.0.3",
"click-option-group",
"colorama",
"flask-cors>=4.0.2, <7.0",
"croniter>=0.3.28",
"cron-descriptor",
"cryptography>=42.0.4, <45.0.0",
@@ -115,7 +116,6 @@ bigquery = [
]
clickhouse = ["clickhouse-connect>=0.5.14, <1.0"]
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
cors = ["flask-cors>=4.0.2, <5.0"]
crate = ["sqlalchemy-cratedb>=0.40.1, <1"]
databend = ["databend-sqlalchemy>=0.3.2, <1.0"]
databricks = [

View File

@@ -7,7 +7,14 @@ To alter the pinned dependency, you can edit/alter the `.in` and `pyproject.toml
```bash
./scripts/uv-pip-compile.sh
```
:::warning
The pinned dependencies are based on the `current` version of python supported in Superset.
Output of `./scripts/uv-pip-compile.sh` may vary slightly based on the python version you are using to run the command.
Check the `pyproject.toml` file for the current version of python supported.
:::
This will generate the pinned requirements in the `.txt` files, which will be used in our CI/CD pipelines and in the Docker images.
We recommend to everyone in the community to use the pinned requirements in their local development environments, to ensure consistency across different environments, though we don't force requirements as part of our python package semantics to allow flexibility for users to install different versions of the dependencies if they wish.
Note that `development.txt` is a superset of what's in `base.txt`, and all version numbers for shared library should fully match at all times. `translations.txt` is meant as a supplemental file to be used in conjunction with the other requirements files, and is not meant to be used standalone.

View File

@@ -36,6 +36,3 @@ marshmallow-sqlalchemy>=1.3.0,<1.4.1
# needed for python 3.12 support
openapi-schema-validator>=0.6.3
# needed when using the flask-cors extension
.[cors]

View File

@@ -154,7 +154,6 @@ greenlet==3.1.1
# via
# apache-superset (pyproject.toml)
# shillelagh
# sqlalchemy
gunicorn==23.0.0
# via apache-superset (pyproject.toml)
h11==0.16.0

View File

@@ -16,4 +16,4 @@
# specific language governing permissions and limitations
# under the License.
#
-e .[development,bigquery,cors,druid,gevent,gsheets,mysql,postgres,presto,prophet,trino,thumbnails]
-e .[development,bigquery,druid,gevent,gsheets,mysql,postgres,presto,prophet,trino,thumbnails]

View File

@@ -1,19 +1,28 @@
# This file was autogenerated by uv via the following command:
# uv pip compile pyproject.toml requirements/development.in -o requirements/development.txt
# uv pip compile requirements/development.in -c requirements/base.txt -o requirements/development.txt
-e .
# via -r requirements/development.in
alembic==1.15.2
# via flask-migrate
# via
# -c requirements/base.txt
# flask-migrate
amqp==5.3.1
# via kombu
# via
# -c requirements/base.txt
# kombu
apispec==6.6.1
# via flask-appbuilder
# via
# -c requirements/base.txt
# flask-appbuilder
apsw==3.50.1.0
# via shillelagh
# via
# -c requirements/base.txt
# shillelagh
astroid==3.3.10
# via pylint
attrs==25.3.0
# via
# -c requirements/base.txt
# cattrs
# jsonschema
# outcome
@@ -21,50 +30,69 @@ attrs==25.3.0
# requests-cache
# trio
babel==2.17.0
# via flask-babel
# via
# -c requirements/base.txt
# flask-babel
backoff==2.2.1
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
bcrypt==4.3.0
# via paramiko
# via
# -c requirements/base.txt
# paramiko
billiard==4.2.1
# via celery
# via
# -c requirements/base.txt
# celery
blinker==1.9.0
# via flask
# via
# -c requirements/base.txt
# flask
bottleneck==1.5.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
brotli==1.1.0
# via flask-compress
# via
# -c requirements/base.txt
# flask-compress
cachelib==0.13.0
# via
# -c requirements/base.txt
# flask-caching
# flask-session
cachetools==5.5.2
# via google-auth
# via
# -c requirements/base.txt
# google-auth
cattrs==25.1.1
# via requests-cache
# via
# -c requirements/base.txt
# requests-cache
celery==5.5.2
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
certifi==2025.6.15
# via
# -c requirements/base.txt
# requests
# selenium
cffi==1.17.1
# via
# -c requirements/base.txt
# cryptography
# pynacl
cfgv==3.4.0
# via pre-commit
charset-normalizer==3.4.2
# via requests
# via
# -c requirements/base.txt
# requests
click==8.2.1
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# celery
# click-didyoumean
@@ -74,20 +102,26 @@ click==8.2.1
# flask
# flask-appbuilder
click-didyoumean==0.3.1
# via celery
# via
# -c requirements/base.txt
# celery
click-option-group==0.5.7
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
click-plugins==1.1.1
# via celery
# via
# -c requirements/base.txt
# celery
click-repl==0.3.0
# via celery
# via
# -c requirements/base.txt
# celery
cmdstanpy==1.1.0
# via prophet
colorama==0.4.6
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# flask-appbuilder
contourpy==1.0.7
@@ -96,15 +130,15 @@ coverage==7.6.8
# via pytest-cov
cron-descriptor==1.4.5
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
croniter==6.0.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
cryptography==44.0.3
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# paramiko
# pyopenssl
@@ -113,30 +147,40 @@ cycler==0.12.1
db-dtypes==1.3.1
# via pandas-gbq
defusedxml==0.7.1
# via odfpy
# via
# -c requirements/base.txt
# odfpy
deprecated==1.2.18
# via limits
# via
# -c requirements/base.txt
# limits
deprecation==2.1.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
dill==0.4.0
# via pylint
distlib==0.3.8
# via virtualenv
dnspython==2.7.0
# via email-validator
# via
# -c requirements/base.txt
# email-validator
docker==7.0.0
# via apache-superset
email-validator==2.2.0
# via flask-appbuilder
# via
# -c requirements/base.txt
# flask-appbuilder
et-xmlfile==2.0.0
# via openpyxl
# via
# -c requirements/base.txt
# openpyxl
filelock==3.12.2
# via virtualenv
flask==2.3.3
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# flask-appbuilder
# flask-babel
@@ -153,50 +197,59 @@ flask==2.3.3
# flask-wtf
flask-appbuilder==4.8.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
flask-babel==2.0.0
# via flask-appbuilder
# via
# -c requirements/base.txt
# flask-appbuilder
flask-caching==2.3.1
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
flask-compress==1.17
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
flask-cors==4.0.2
# via apache-superset
# via
# -c requirements/base.txt
# apache-superset
flask-jwt-extended==4.7.1
# via flask-appbuilder
# via
# -c requirements/base.txt
# flask-appbuilder
flask-limiter==3.12
# via flask-appbuilder
# via
# -c requirements/base.txt
# flask-appbuilder
flask-login==0.6.3
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# flask-appbuilder
flask-migrate==3.1.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
flask-session==0.8.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
flask-sqlalchemy==2.5.1
# via
# -c requirements/base.txt
# flask-appbuilder
# flask-migrate
flask-talisman==1.1.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
flask-testing==0.8.1
# via apache-superset
flask-wtf==1.2.2
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# flask-appbuilder
fonttools==4.55.0
@@ -206,10 +259,12 @@ freezegun==1.5.1
future==1.0.0
# via pyhive
geographiclib==2.0
# via geopy
# via
# -c requirements/base.txt
# geopy
geopy==2.4.1
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
gevent==24.2.1
# via apache-superset
@@ -222,6 +277,7 @@ google-api-core==2.23.0
# sqlalchemy-bigquery
google-auth==2.40.3
# via
# -c requirements/base.txt
# google-api-core
# google-auth-oauthlib
# google-cloud-bigquery
@@ -253,11 +309,10 @@ googleapis-common-protos==1.66.0
# grpcio-status
greenlet==3.1.1
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# gevent
# shillelagh
# sqlalchemy
grpcio==1.71.0
# via
# apache-superset
@@ -267,27 +322,30 @@ grpcio-status==1.60.1
# via google-api-core
gunicorn==23.0.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
h11==0.16.0
# via wsproto
# via
# -c requirements/base.txt
# wsproto
hashids==1.3.1
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
holidays==0.25
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# prophet
humanize==4.12.3
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
identify==2.5.36
# via pre-commit
idna==3.10
# via
# -c requirements/base.txt
# email-validator
# requests
# trio
@@ -298,24 +356,27 @@ iniconfig==2.0.0
# via pytest
isodate==0.7.2
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
isort==6.0.1
# via pylint
itsdangerous==2.2.0
# via
# -c requirements/base.txt
# flask
# flask-wtf
jinja2==3.1.6
# via
# -c requirements/base.txt
# flask
# flask-babel
jsonpath-ng==1.7.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
jsonschema==4.23.0
# via
# -c requirements/base.txt
# flask-appbuilder
# openapi-schema-validator
# openapi-spec-validator
@@ -323,66 +384,82 @@ jsonschema-path==0.3.4
# via openapi-spec-validator
jsonschema-specifications==2025.4.1
# via
# -c requirements/base.txt
# jsonschema
# openapi-schema-validator
kiwisolver==1.4.7
# via matplotlib
kombu==5.5.3
# via celery
# via
# -c requirements/base.txt
# celery
korean-lunar-calendar==0.3.1
# via holidays
# via
# -c requirements/base.txt
# holidays
lazy-object-proxy==1.10.0
# via openapi-spec-validator
limits==5.1.0
# via flask-limiter
# via
# -c requirements/base.txt
# flask-limiter
mako==1.3.10
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# alembic
# apache-superset
markdown==3.8
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
markdown-it-py==3.0.0
# via rich
# via
# -c requirements/base.txt
# rich
markupsafe==3.0.2
# via
# -c requirements/base.txt
# jinja2
# mako
# werkzeug
# wtforms
marshmallow==3.26.1
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# flask-appbuilder
# marshmallow-sqlalchemy
marshmallow-sqlalchemy==1.4.0
# via flask-appbuilder
# via
# -c requirements/base.txt
# flask-appbuilder
matplotlib==3.9.0
# via prophet
mccabe==0.7.0
# via pylint
mdurl==0.1.2
# via markdown-it-py
# via
# -c requirements/base.txt
# markdown-it-py
msgpack==1.0.8
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
msgspec==0.19.0
# via flask-session
# via
# -c requirements/base.txt
# flask-session
mysqlclient==2.2.6
# via apache-superset
nh3==0.2.21
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
nodeenv==1.8.0
# via pre-commit
numpy==1.26.4
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# bottleneck
# cmdstanpy
@@ -395,22 +472,31 @@ numpy==1.26.4
oauthlib==3.2.2
# via requests-oauthlib
odfpy==1.4.1
# via pandas
# via
# -c requirements/base.txt
# pandas
openapi-schema-validator==0.6.3
# via openapi-spec-validator
# via
# -c requirements/base.txt
# openapi-spec-validator
openapi-spec-validator==0.7.1
# via apache-superset
openpyxl==3.1.5
# via pandas
# via
# -c requirements/base.txt
# pandas
ordered-set==4.1.0
# via flask-limiter
# via
# -c requirements/base.txt
# flask-limiter
outcome==1.3.0.post0
# via
# -c requirements/base.txt
# trio
# trio-websocket
packaging==25.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# apispec
# db-dtypes
@@ -426,7 +512,7 @@ packaging==25.0
# sqlalchemy-bigquery
pandas==2.0.3
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# cmdstanpy
# db-dtypes
@@ -438,18 +524,18 @@ parameterized==0.9.0
# via apache-superset
paramiko==3.5.1
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# sshtunnel
parsedatetime==2.6
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
pathable==0.4.3
# via jsonschema-path
pgsanity==0.2.9
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
pillow==10.3.0
# via
@@ -457,25 +543,32 @@ pillow==10.3.0
# matplotlib
platformdirs==4.3.8
# via
# -c requirements/base.txt
# pylint
# requests-cache
# virtualenv
pluggy==1.5.0
# via pytest
ply==3.11
# via jsonpath-ng
# via
# -c requirements/base.txt
# jsonpath-ng
polyline==2.0.2
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
pre-commit==4.1.0
# via apache-superset
prison==0.2.1
# via flask-appbuilder
# via
# -c requirements/base.txt
# flask-appbuilder
progress==1.6
# via apache-superset
prompt-toolkit==3.0.51
# via click-repl
# via
# -c requirements/base.txt
# click-repl
prophet==1.1.5
# via apache-superset
proto-plus==1.25.0
@@ -495,21 +588,25 @@ psycopg2-binary==2.9.6
# via apache-superset
pyarrow==18.1.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# db-dtypes
# pandas-gbq
pyasn1==0.6.1
# via
# -c requirements/base.txt
# pyasn1-modules
# python-ldap
# rsa
pyasn1-modules==0.4.2
# via
# -c requirements/base.txt
# google-auth
# python-ldap
pycparser==2.22
# via cffi
# via
# -c requirements/base.txt
# cffi
pydata-google-auth==1.9.0
# via pandas-gbq
pydruid==0.6.9
@@ -517,30 +614,38 @@ pydruid==0.6.9
pyfakefs==5.3.5
# via apache-superset
pygments==2.19.1
# via rich
# via
# -c requirements/base.txt
# rich
pyhive==0.7.0
# via apache-superset
pyinstrument==4.4.0
# via apache-superset
pyjwt==2.10.1
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# flask-appbuilder
# flask-jwt-extended
pylint==3.3.7
# via apache-superset
pynacl==1.5.0
# via paramiko
# via
# -c requirements/base.txt
# paramiko
pyopenssl==25.1.0
# via shillelagh
# via
# -c requirements/base.txt
# shillelagh
pyparsing==3.2.3
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# matplotlib
pysocks==1.7.1
# via urllib3
# via
# -c requirements/base.txt
# urllib3
pytest==7.4.4
# via
# apache-superset
@@ -552,7 +657,7 @@ pytest-mock==3.10.0
# via apache-superset
python-dateutil==2.9.0.post0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# celery
# croniter
@@ -567,40 +672,45 @@ python-dateutil==2.9.0.post0
# trino
python-dotenv==1.1.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
python-geohash==0.8.5
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
python-ldap==3.4.4
# via apache-superset
pytz==2025.2
# via
# -c requirements/base.txt
# croniter
# flask-babel
# pandas
# trino
pyxlsb==1.0.10
# via pandas
# via
# -c requirements/base.txt
# pandas
pyyaml==6.0.2
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# apispec
# jsonschema-path
# pre-commit
redis==4.6.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
referencing==0.36.2
# via
# -c requirements/base.txt
# jsonschema
# jsonschema-path
# jsonschema-specifications
requests==2.32.4
# via
# -c requirements/base.txt
# docker
# google-api-core
# google-cloud-bigquery
@@ -612,24 +722,33 @@ requests==2.32.4
# shillelagh
# trino
requests-cache==1.2.1
# via shillelagh
# via
# -c requirements/base.txt
# shillelagh
requests-oauthlib==2.0.0
# via google-auth-oauthlib
rfc3339-validator==0.1.4
# via openapi-schema-validator
# via
# -c requirements/base.txt
# openapi-schema-validator
rich==13.9.4
# via flask-limiter
# via
# -c requirements/base.txt
# flask-limiter
rpds-py==0.25.0
# via
# -c requirements/base.txt
# jsonschema
# referencing
rsa==4.9.1
# via google-auth
# via
# -c requirements/base.txt
# google-auth
ruff==0.8.0
# via apache-superset
selenium==4.32.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
setuptools==80.7.1
# via
@@ -640,29 +759,34 @@ setuptools==80.7.1
# zope-interface
shillelagh==1.3.5
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
simplejson==3.20.1
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
six==1.17.0
# via
# -c requirements/base.txt
# prison
# python-dateutil
# rfc3339-validator
# wtforms-json
slack-sdk==3.35.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
sniffio==1.3.1
# via trio
# via
# -c requirements/base.txt
# trio
sortedcontainers==2.4.0
# via trio
# via
# -c requirements/base.txt
# trio
sqlalchemy==1.4.54
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# alembic
# apache-superset
# flask-appbuilder
@@ -675,24 +799,24 @@ sqlalchemy-bigquery==1.12.0
# via apache-superset
sqlalchemy-utils==0.38.3
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# flask-appbuilder
sqlglot==26.28.1
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
sqloxide==0.1.51
# via apache-superset
sshtunnel==0.4.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
statsd==4.0.1
# via apache-superset
tabulate==0.9.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
tomlkit==0.13.3
# via pylint
@@ -704,13 +828,16 @@ trino==0.330.0
# via apache-superset
trio==0.30.0
# via
# -c requirements/base.txt
# selenium
# trio-websocket
trio-websocket==0.12.2
# via selenium
# via
# -c requirements/base.txt
# selenium
typing-extensions==4.14.0
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# alembic
# apache-superset
# cattrs
@@ -721,55 +848,71 @@ typing-extensions==4.14.0
# shillelagh
tzdata==2025.2
# via
# -c requirements/base.txt
# kombu
# pandas
tzlocal==5.2
# via trino
url-normalize==2.2.1
# via requests-cache
# via
# -c requirements/base.txt
# requests-cache
urllib3==2.5.0
# via
# -c requirements/base.txt
# docker
# requests
# requests-cache
# selenium
vine==5.1.0
# via
# -c requirements/base.txt
# amqp
# celery
# kombu
virtualenv==20.29.2
# via pre-commit
wcwidth==0.2.13
# via prompt-toolkit
# via
# -c requirements/base.txt
# prompt-toolkit
websocket-client==1.8.0
# via selenium
# via
# -c requirements/base.txt
# selenium
werkzeug==3.1.3
# via
# -c requirements/base.txt
# flask
# flask-appbuilder
# flask-jwt-extended
# flask-login
wrapt==1.17.2
# via deprecated
# via
# -c requirements/base.txt
# deprecated
wsproto==1.2.0
# via trio-websocket
# via
# -c requirements/base.txt
# trio-websocket
wtforms==3.2.1
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# flask-appbuilder
# flask-wtf
# wtforms-json
wtforms-json==0.3.5
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
xlrd==2.0.1
# via pandas
# via
# -c requirements/base.txt
# pandas
xlsxwriter==3.0.9
# via
# apache-superset (pyproject.toml)
# -c requirements/base.txt
# apache-superset
# pandas
zope-event==5.0
@@ -777,4 +920,6 @@ zope-event==5.0
zope-interface==5.4.0
# via gevent
zstandard==0.23.0
# via flask-compress
# via
# -c requirements/base.txt
# flask-compress

View File

@@ -1,431 +1,4 @@
# This file was autogenerated by uv via the following command:
# uv pip compile pyproject.toml requirements/translations.in -o requirements/translations.txt
alembic==1.16.2
# via flask-migrate
amqp==5.3.1
# via kombu
apispec==6.8.2
# via flask-appbuilder
apsw==3.50.2.0
# via shillelagh
attrs==25.3.0
# via
# cattrs
# jsonschema
# outcome
# referencing
# requests-cache
# trio
# uv pip compile requirements/translations.in -o requirements/translations.txt
babel==2.17.0
# via
# -r requirements/translations.in
# flask-babel
backoff==2.2.1
# via apache-superset (pyproject.toml)
bcrypt==4.3.0
# via paramiko
billiard==4.2.1
# via celery
blinker==1.9.0
# via flask
bottleneck==1.5.0
# via apache-superset (pyproject.toml)
brotli==1.1.0
# via flask-compress
cachelib==0.13.0
# via
# flask-caching
# flask-session
cachetools==5.5.2
# via google-auth
cattrs==25.1.1
# via requests-cache
celery==5.5.3
# via apache-superset (pyproject.toml)
certifi==2025.6.15
# via
# requests
# selenium
cffi==1.17.1
# via
# cryptography
# pynacl
charset-normalizer==3.4.2
# via requests
click==8.2.1
# via
# apache-superset (pyproject.toml)
# celery
# click-didyoumean
# click-option-group
# click-plugins
# click-repl
# flask
# flask-appbuilder
click-didyoumean==0.3.1
# via celery
click-option-group==0.5.7
# via apache-superset (pyproject.toml)
click-plugins==1.1.1.2
# via celery
click-repl==0.3.0
# via celery
colorama==0.4.6
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
cron-descriptor==1.4.5
# via apache-superset (pyproject.toml)
croniter==6.0.0
# via apache-superset (pyproject.toml)
cryptography==44.0.3
# via
# apache-superset (pyproject.toml)
# paramiko
# pyopenssl
defusedxml==0.7.1
# via odfpy
deprecated==1.2.18
# via limits
deprecation==2.1.0
# via apache-superset (pyproject.toml)
dnspython==2.7.0
# via email-validator
email-validator==2.2.0
# via flask-appbuilder
et-xmlfile==2.0.0
# via openpyxl
flask==2.3.3
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
# flask-babel
# flask-caching
# flask-compress
# flask-jwt-extended
# flask-limiter
# flask-login
# flask-migrate
# flask-session
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==4.8.0
# via apache-superset (pyproject.toml)
flask-babel==2.0.0
# via flask-appbuilder
flask-caching==2.3.1
# via apache-superset (pyproject.toml)
flask-compress==1.17
# via apache-superset (pyproject.toml)
flask-jwt-extended==4.7.1
# via flask-appbuilder
flask-limiter==3.12
# via flask-appbuilder
flask-login==0.6.3
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
flask-migrate==3.1.0
# via apache-superset (pyproject.toml)
flask-session==0.8.0
# via apache-superset (pyproject.toml)
flask-sqlalchemy==2.5.1
# via
# flask-appbuilder
# flask-migrate
flask-talisman==1.1.0
# via apache-superset (pyproject.toml)
flask-wtf==1.2.2
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
geographiclib==2.0
# via geopy
geopy==2.4.1
# via apache-superset (pyproject.toml)
google-auth==2.40.3
# via shillelagh
greenlet==3.1.1
# via
# apache-superset (pyproject.toml)
# shillelagh
# sqlalchemy
gunicorn==23.0.0
# via apache-superset (pyproject.toml)
h11==0.16.0
# via wsproto
hashids==1.3.1
# via apache-superset (pyproject.toml)
holidays==0.25
# via apache-superset (pyproject.toml)
humanize==4.12.3
# via apache-superset (pyproject.toml)
idna==3.10
# via
# email-validator
# requests
# trio
# url-normalize
isodate==0.7.2
# via apache-superset (pyproject.toml)
itsdangerous==2.2.0
# via
# flask
# flask-wtf
jinja2==3.1.6
# via
# flask
# flask-babel
jsonpath-ng==1.7.0
# via apache-superset (pyproject.toml)
jsonschema==4.24.0
# via flask-appbuilder
jsonschema-specifications==2025.4.1
# via jsonschema
kombu==5.5.4
# via celery
korean-lunar-calendar==0.3.1
# via holidays
limits==5.4.0
# via flask-limiter
mako==1.3.10
# via
# apache-superset (pyproject.toml)
# alembic
markdown==3.8.2
# via apache-superset (pyproject.toml)
markdown-it-py==3.0.0
# via rich
markupsafe==3.0.2
# via
# jinja2
# mako
# werkzeug
# wtforms
marshmallow==3.26.1
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
# marshmallow-sqlalchemy
marshmallow-sqlalchemy==1.4.2
# via flask-appbuilder
mdurl==0.1.2
# via markdown-it-py
msgpack==1.0.8
# via apache-superset (pyproject.toml)
msgspec==0.19.0
# via flask-session
nh3==0.2.21
# via apache-superset (pyproject.toml)
numpy==2.2.6
# via
# apache-superset (pyproject.toml)
# bottleneck
# pandas
odfpy==1.4.1
# via pandas
openpyxl==3.1.5
# via pandas
ordered-set==4.1.0
# via flask-limiter
outcome==1.3.0.post0
# via
# trio
# trio-websocket
packaging==25.0
# via
# apache-superset (pyproject.toml)
# apispec
# deprecation
# gunicorn
# kombu
# limits
# marshmallow
# shillelagh
pandas==2.0.3
# via apache-superset (pyproject.toml)
paramiko==3.5.1
# via
# apache-superset (pyproject.toml)
# sshtunnel
parsedatetime==2.6
# via apache-superset (pyproject.toml)
pgsanity==0.2.9
# 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
# via flask-appbuilder
prompt-toolkit==3.0.51
# via click-repl
pyarrow==18.1.0
# via apache-superset (pyproject.toml)
pyasn1==0.6.1
# via
# pyasn1-modules
# rsa
pyasn1-modules==0.4.2
# via google-auth
pycparser==2.22
# via cffi
pygments==2.19.2
# via rich
pyjwt==2.10.1
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
# flask-jwt-extended
pynacl==1.5.0
# via paramiko
pyopenssl==25.1.0
# via shillelagh
pyparsing==3.2.3
# via apache-superset (pyproject.toml)
pysocks==1.7.1
# via urllib3
python-dateutil==2.9.0.post0
# via
# apache-superset (pyproject.toml)
# celery
# croniter
# flask-appbuilder
# holidays
# pandas
# shillelagh
python-dotenv==1.1.1
# via apache-superset (pyproject.toml)
python-geohash==0.8.5
# via apache-superset (pyproject.toml)
pytz==2025.2
# via
# croniter
# flask-babel
# pandas
pyxlsb==1.0.10
# via pandas
pyyaml==6.0.2
# via
# apache-superset (pyproject.toml)
# apispec
redis==4.6.0
# via apache-superset (pyproject.toml)
referencing==0.36.2
# via
# jsonschema
# jsonschema-specifications
requests==2.32.4
# via
# requests-cache
# shillelagh
requests-cache==1.2.1
# via shillelagh
rich==13.9.4
# via flask-limiter
rpds-py==0.25.1
# via
# jsonschema
# referencing
rsa==4.9.1
# via google-auth
selenium==4.34.0
# via apache-superset (pyproject.toml)
shillelagh==1.3.5
# via apache-superset (pyproject.toml)
simplejson==3.20.1
# via apache-superset (pyproject.toml)
six==1.17.0
# via
# prison
# python-dateutil
# wtforms-json
slack-sdk==3.35.0
# via apache-superset (pyproject.toml)
sniffio==1.3.1
# via trio
sortedcontainers==2.4.0
# via trio
sqlalchemy==1.4.54
# via
# apache-superset (pyproject.toml)
# alembic
# flask-appbuilder
# flask-sqlalchemy
# marshmallow-sqlalchemy
# shillelagh
# sqlalchemy-utils
sqlalchemy-utils==0.38.3
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
sqlglot==26.31.0
# via apache-superset (pyproject.toml)
sshtunnel==0.4.0
# via apache-superset (pyproject.toml)
tabulate==0.9.0
# via apache-superset (pyproject.toml)
trio==0.30.0
# via
# selenium
# trio-websocket
trio-websocket==0.12.2
# via selenium
typing-extensions==4.14.0
# via
# apache-superset (pyproject.toml)
# alembic
# cattrs
# limits
# pyopenssl
# referencing
# selenium
# shillelagh
tzdata==2025.2
# via
# kombu
# pandas
url-normalize==2.2.1
# via requests-cache
urllib3==2.4.0
# via
# requests
# requests-cache
# selenium
vine==5.1.0
# via
# amqp
# celery
# kombu
wcwidth==0.2.13
# via prompt-toolkit
websocket-client==1.8.0
# via selenium
werkzeug==3.1.3
# via
# flask
# flask-appbuilder
# flask-jwt-extended
# flask-login
wrapt==1.17.2
# via deprecated
wsproto==1.2.0
# via trio-websocket
wtforms==3.2.1
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
# flask-wtf
# wtforms-json
wtforms-json==0.3.5
# via apache-superset (pyproject.toml)
xlrd==2.0.2
# via pandas
xlsxwriter==3.0.9
# via
# apache-superset (pyproject.toml)
# pandas
zstandard==0.23.0
# via flask-compress
# via -r requirements/translations.in

View File

@@ -24,7 +24,8 @@ ADDITIONAL_ARGS="$@"
# Generate the requirements/base.txt file
uv pip compile pyproject.toml requirements/base.in -o requirements/base.txt $ADDITIONAL_ARGS
# Generate the requirements/development.txt file, making sure requirements/base.txt is a constraint to keep the versions in sync
# Generate the requirements/development.txt file, making sure requirements/base.txt is a constraint to keep the versions in sync. Note that `development.txt` is a Superset of `base.txt` where version for the shared libs should match their version.
uv pip compile requirements/development.in -c requirements/base.txt -o requirements/development.txt $ADDITIONAL_ARGS
# NOTE translation is intended as a "supplemental" set of pins that can be combined with either base or dev as needed
uv pip compile requirements/translations.in -o requirements/translations.txt $ADDITIONAL_ARGS

View File

@@ -46,10 +46,10 @@ module.exports = {
plugins: [
'lodash',
'@babel/plugin-syntax-dynamic-import',
['@babel/plugin-proposal-class-properties', { loose: true }],
['@babel/plugin-proposal-optional-chaining', { loose: true }],
['@babel/plugin-proposal-private-methods', { loose: true }],
['@babel/plugin-proposal-nullish-coalescing-operator', { loose: true }],
['@babel/plugin-transform-class-properties', { loose: true }],
['@babel/plugin-transform-optional-chaining', { loose: true }],
['@babel/plugin-transform-private-methods', { loose: true }],
['@babel/plugin-transform-nullish-coalescing-operator', { loose: true }],
['@babel/plugin-transform-runtime', { corejs: 3 }],
// only used in packages/superset-ui-core/src/chart/components/reactify.tsx
['babel-plugin-typescript-to-proptypes', { loose: true }],

View File

@@ -56,7 +56,7 @@ module.exports = {
],
coverageReporters: ['lcov', 'json-summary', 'html', 'text'],
transformIgnorePatterns: [
'node_modules/(?!d3-(interpolate|color|time)|remark-gfm|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|@rjsf/*.|sinon|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|jest-enzyme|geostyler|geostyler-.*)',
'node_modules/(?!d3-(interpolate|color|time)|remark-gfm|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|@rjsf/*.|sinon|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-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es)',
],
preset: 'ts-jest',
transform: {

File diff suppressed because it is too large Load Diff

View File

@@ -176,7 +176,7 @@
"react-google-recaptcha": "^3.1.0",
"react-hot-loader": "^4.13.1",
"react-intersection-observer": "^9.16.0",
"react-json-tree": "^0.17.0",
"react-json-tree": "^0.20.0",
"react-lines-ellipsis": "^0.15.4",
"react-loadable": "^5.5.0",
"react-redux": "^7.2.9",
@@ -212,10 +212,6 @@
"@babel/core": "^7.26.0",
"@babel/eslint-parser": "^7.25.9",
"@babel/node": "^7.22.6",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
"@babel/plugin-proposal-optional-chaining": "^7.21.0",
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-modules-commonjs": "^7.26.3",
"@babel/plugin-transform-runtime": "^7.27.1",

View File

@@ -90,6 +90,7 @@ export interface Dataset {
database?: Record<string, unknown>;
normalize_columns?: boolean;
always_filter_main_dttm?: boolean;
extra?: object | string;
}
export interface ControlPanelState {
@@ -161,6 +162,7 @@ export type InternalControlType =
| 'DatasourceControl'
| 'DateFilterControl'
| 'FixedOrMetricControl'
| 'ColorBreakpointsControl'
| 'HiddenControl'
| 'SelectAsyncControl'
| 'SelectControl'

View File

@@ -53,7 +53,7 @@
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^15.4.5",
"react-ultimate-pagination": "^1.3.2",
"react-error-boundary": "^5.0.0",
"react-error-boundary": "^6.0.0",
"react-markdown": "^8.0.7",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",

View File

@@ -62,12 +62,12 @@ test('Calling "onHide"', async () => {
expect(props.onConfirm).toHaveBeenCalledTimes(0);
// type "del" in the input
await userEvent.type(screen.getByTestId('delete-modal-input'), 'del');
userEvent.type(screen.getByTestId('delete-modal-input'), 'del');
expect(screen.getByTestId('delete-modal-input')).toHaveValue('del');
// close the modal
expect(screen.getByText('×')).toBeInTheDocument();
await userEvent.click(screen.getByText('×'));
expect(screen.getByTestId('close-modal-btn')).toBeInTheDocument();
userEvent.click(screen.getByTestId('close-modal-btn'));
expect(props.onHide).toHaveBeenCalledTimes(1);
expect(props.onConfirm).toHaveBeenCalledTimes(0);

View File

@@ -18,7 +18,7 @@
*/
import { isValidElement, cloneElement, useMemo, useRef, useState } from 'react';
import { isNil } from 'lodash';
import { css, styled, t } from '@superset-ui/core';
import { css, styled, t, useTheme } from '@superset-ui/core';
import { Modal as AntdModal, ModalProps as AntdModalProps } from 'antd';
import { Resizable } from 're-resizable';
import Draggable, {
@@ -26,6 +26,7 @@ import Draggable, {
DraggableData,
DraggableEvent,
} from 'react-draggable';
import { Icons } from '../Icons';
import { Button } from '../Button';
import type { ModalProps, StyledModalProps } from './types';
@@ -45,8 +46,16 @@ export const BaseModal = (props: AntdModalProps) => (
);
export const StyledModal = styled(BaseModal)<StyledModalProps>`
${({ theme, responsive, maxWidth }) =>
responsive &&
${({
theme,
responsive,
maxWidth,
resizable,
height,
draggable,
hideFooter,
}) => css`
${responsive &&
css`
max-width: ${maxWidth ?? '900px'};
padding-left: ${theme.sizeUnit * 3}px;
@@ -55,120 +64,120 @@ export const StyledModal = styled(BaseModal)<StyledModalProps>`
top: 0;
`}
.ant-modal-content {
background-color: ${({ theme }) => theme.colorBgContainer};
display: flex;
flex-direction: column;
max-height: ${({ theme }) => `calc(100vh - ${theme.sizeUnit * 8}px)`};
margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px;
margin-top: ${({ theme }) => theme.sizeUnit * 4}px;
padding: 0;
}
.ant-modal-header {
flex: 0 0 auto;
border-radius: ${({ theme }) => theme.borderRadius}px
${({ theme }) => theme.borderRadius}px 0 0;
padding: ${({ theme }) => theme.sizeUnit * 4}px
${({ theme }) => theme.sizeUnit * 6}px;
.ant-modal-title {
font-weight: ${({ theme }) => theme.fontWeightStrong};
}
.ant-modal-title h4 {
.ant-modal-content {
background-color: ${theme.colorBgContainer};
display: flex;
margin: 0;
align-items: center;
}
}
.ant-modal-close {
width: ${({ theme }) => theme.sizeUnit * 14}px;
height: ${({ theme }) => theme.sizeUnit * 14}px;
top: 0;
right: 0;
}
.ant-modal-close:hover {
background: transparent;
}
.ant-modal-close-x {
display: flex;
align-items: center;
.close {
flex: 1 1 auto;
margin-bottom: ${({ theme }) => theme.sizeUnit}px;
color: ${({ theme }) => theme.colorPrimaryText};
font-size: 32px;
font-weight: ${({ theme }) => theme.fontWeightLight};
}
}
.ant-modal-body {
flex: 0 1 auto;
padding: ${({ theme }) => theme.sizeUnit * 4}px;
overflow: auto;
${({ resizable, height }) => !resizable && height && `height: ${height};`}
}
.ant-modal-footer {
flex: 0 0 1;
border-top: ${({ theme }) => theme.sizeUnit / 4}px solid
${({ theme }) => theme.colorSplit};
padding: ${({ theme }) => theme.sizeUnit * 4}px;
margin-top: 0;
.btn {
font-size: 12px;
}
.btn + .btn {
margin-left: ${({ theme }) => theme.sizeUnit * 2}px;
}
}
&.no-content-padding .ant-modal-body {
padding: 0;
}
${({ draggable, theme }) =>
draggable &&
`
.ant-modal-header {
flex-direction: column;
max-height: calc(100vh - ${theme.sizeUnit * 8}px);
margin-bottom: ${theme.sizeUnit * 4}px;
margin-top: ${theme.sizeUnit * 4}px;
padding: 0;
.draggable-trigger {
}
.ant-modal-header {
flex: 0 0 auto;
border-radius: ${theme.borderRadius}px ${theme.borderRadius}px 0 0;
padding: ${theme.sizeUnit * 4}px ${theme.sizeUnit * 6}px;
.ant-modal-title {
font-weight: ${theme.fontWeightStrong};
}
.ant-modal-title h4 {
display: flex;
margin: 0;
align-items: center;
}
}
.ant-modal-close {
width: ${theme.sizeUnit * 14}px;
height: ${theme.sizeUnit * 14}px;
padding: ${theme.sizeUnit * 6}px ${theme.sizeUnit * 4}px
${theme.sizeUnit * 4}px;
top: 0;
right: 0;
display: flex;
justify-content: center;
}
.ant-modal-close:hover {
background: transparent;
}
.ant-modal-close-x {
display: flex;
align-items: center;
[data-test='close-modal-btn'] {
justify-content: center;
}
.close {
flex: 1 1 auto;
margin-bottom: ${theme.sizeUnit}px;
color: ${theme.colorPrimaryText};
font-weight: ${theme.fontWeightLight};
}
}
.ant-modal-body {
flex: 0 1 auto;
padding: ${theme.sizeUnit * 4}px;
overflow: auto;
${!resizable && height && `height: ${height};`}
}
.ant-modal-footer {
flex: 0 0 1;
border-top: ${theme.sizeUnit / 4}px solid ${theme.colorSplit};
padding: ${theme.sizeUnit * 4}px;
margin-top: 0;
.btn {
font-size: 12px;
}
.btn + .btn {
margin-left: ${theme.sizeUnit * 2}px;
}
}
&.no-content-padding .ant-modal-body {
padding: 0;
}
${draggable &&
css`
.ant-modal-header {
padding: 0;
.draggable-trigger {
cursor: move;
padding: ${theme.sizeUnit * 4}px;
width: 100%;
}
}
`};
${({ resizable, hideFooter }) =>
resizable &&
`
.resizable {
pointer-events: all;
.resizable-wrapper {
height: 100%;
}
`}
.ant-modal-content {
height: 100%;
${resizable &&
css`
.resizable {
pointer-events: all;
.ant-modal-body {
/* 100% - header height - footer height */
height: ${
hideFooter
? `calc(100% - ${MODAL_HEADER_HEIGHT}px);`
: `calc(100% - ${MODAL_HEADER_HEIGHT}px - ${MODAL_FOOTER_HEIGHT}px);`
.resizable-wrapper {
height: 100%;
}
.ant-modal-content {
height: 100%;
.ant-modal-body {
height: ${hideFooter
? `calc(100% - ${MODAL_HEADER_HEIGHT}px)`
: `calc(100% - ${MODAL_HEADER_HEIGHT}px - ${MODAL_FOOTER_HEIGHT}px)`};
}
}
}
}
`}
`}
`;
@@ -221,6 +230,7 @@ const CustomModal = ({
const draggableRef = useRef<HTMLDivElement>(null);
const [bounds, setBounds] = useState<DraggableBounds>();
const [dragDisabled, setDragDisabled] = useState<boolean>(true);
const theme = useTheme();
const handleOnHide = () => {
openerRef?.current?.focus();
@@ -312,9 +322,13 @@ const CustomModal = ({
open={show}
title={<ModalTitle />}
closeIcon={
<span data-test="close-modal-btn" className="close" aria-hidden="true">
×
</span>
<Icons.CloseOutlined
iconColor={theme.colorText}
iconSize="l"
data-test="close-modal-btn"
className="close"
aria-hidden="true"
/>
}
footer={!hideFooter ? modalFooter : null}
hideFooter={hideFooter}

View File

@@ -45,7 +45,7 @@ interface HandleSelectProps {
}
const menuItemStyles = (theme: any) => css`
&.antd5-menu-item {
&.ant-menu-item {
height: auto;
line-height: 1.4;

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import {
Radio as Antd5Radio,
Radio as AntRadio,
type CheckboxOptionType,
type RadioGroupProps,
} from 'antd';
@@ -40,19 +40,19 @@ const RadioGroup = ({
...props
}: RadioGroupWrapperProps) => {
const content = options.map((option: CheckboxOptionType) => (
<Antd5Radio key={option.value} value={option.value}>
<AntRadio key={option.value} value={option.value}>
{option.label}
</Antd5Radio>
</AntRadio>
));
return (
<Antd5Radio.Group {...props}>
<AntRadio.Group {...props}>
{spaceConfig ? <Space {...spaceConfig}>{content}</Space> : content}
</Antd5Radio.Group>
</AntRadio.Group>
);
};
export const Radio = Object.assign(Antd5Radio, {
export const Radio = Object.assign(AntRadio, {
GroupWrapper: RadioGroup,
Button: Antd5Radio.Button,
Button: AntRadio.Button,
});
export type {
RadioChangeEvent,

View File

@@ -19,28 +19,28 @@
import { Tooltip } from 'antd';
import { Dropdown, Icons } from '@superset-ui/core/components';
import { t } from '@superset-ui/core';
import { ThemeMode } from '../../theme/types';
import { ThemeAlgorithm, ThemeMode } from '../../theme/types';
export interface ThemeSelectProps {
changeThemeMode: (newMode: ThemeMode) => void;
setThemeMode: (newMode: ThemeMode) => void;
tooltipTitle?: string;
themeMode: ThemeMode;
}
const ThemeSelect: React.FC<ThemeSelectProps> = ({
changeThemeMode,
setThemeMode,
tooltipTitle = 'Select theme',
themeMode,
}) => {
const handleSelect = (mode: ThemeMode) => {
changeThemeMode(mode);
setThemeMode(mode);
};
const themeIconMap: Record<ThemeMode, React.ReactNode> = {
[ThemeMode.LIGHT]: <Icons.SunOutlined />,
[ThemeMode.DARK]: <Icons.MoonOutlined />,
const themeIconMap: Record<ThemeAlgorithm | ThemeMode, React.ReactNode> = {
[ThemeAlgorithm.DEFAULT]: <Icons.SunOutlined />,
[ThemeAlgorithm.DARK]: <Icons.MoonOutlined />,
[ThemeMode.SYSTEM]: <Icons.FormatPainterOutlined />,
[ThemeMode.COMPACT]: <Icons.CompressOutlined />,
[ThemeAlgorithm.COMPACT]: <Icons.CompressOutlined />,
};
return (
@@ -49,9 +49,9 @@ const ThemeSelect: React.FC<ThemeSelectProps> = ({
menu={{
items: [
{
key: ThemeMode.LIGHT,
key: ThemeMode.DEFAULT,
label: t('Light'),
onClick: () => handleSelect(ThemeMode.LIGHT),
onClick: () => handleSelect(ThemeMode.DEFAULT),
icon: <Icons.SunOutlined />,
},
{

View File

@@ -18,7 +18,7 @@
*/
import { theme as antdThemeImport } from 'antd';
import { Theme } from './Theme';
import { AnyThemeConfig } from './types';
import { AnyThemeConfig, ThemeAlgorithm } from './types';
// Mock emotion's cache to avoid actual DOM operations
jest.mock('@emotion/cache', () => ({
@@ -44,7 +44,7 @@ describe('Theme', () => {
const parsedJson = JSON.parse(jsonString);
expect(parsedJson.token?.colorPrimary).toBe('#ff0000');
expect(parsedJson.algorithm).toBe('dark');
expect(parsedJson.algorithm).toBe(ThemeAlgorithm.DARK);
});
});
@@ -91,7 +91,7 @@ describe('Theme', () => {
// Verify dark mode by using the serialized config from the public method
const serialized = theme.toSerializedConfig();
expect(serialized.algorithm).toBe('dark');
expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
});
});
@@ -137,7 +137,7 @@ describe('Theme', () => {
// Verify the algorithm was updated
const serialized = theme.toSerializedConfig();
expect(serialized.algorithm).toBe('dark');
expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
});
});
@@ -150,7 +150,7 @@ describe('Theme', () => {
// Verify dark algorithm is used
const serialized = theme.toSerializedConfig();
expect(serialized.algorithm).toBe('dark');
expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
});
it('switches to default algorithm when toggling dark mode off', () => {
@@ -164,7 +164,7 @@ describe('Theme', () => {
// Verify default algorithm is used
const serialized = theme.toSerializedConfig();
expect(serialized.algorithm).toBe('default');
expect(serialized.algorithm).toBe(ThemeAlgorithm.DEFAULT);
});
it('preserves other algorithms when toggling dark mode', () => {
@@ -181,10 +181,11 @@ describe('Theme', () => {
// Verify default algorithm replaces dark but compact is preserved
const serialized = theme.toSerializedConfig();
expect(Array.isArray(serialized.algorithm)).toBe(true);
expect(serialized.algorithm).toContain('default');
expect(serialized.algorithm).toContain('compact');
expect(serialized.algorithm).not.toContain('dark');
expect(serialized.algorithm).toContain(ThemeAlgorithm.DEFAULT);
expect(serialized.algorithm).toContain(ThemeAlgorithm.COMPACT);
expect(serialized.algorithm).not.toContain(ThemeAlgorithm.DARK);
});
});
@@ -218,7 +219,7 @@ describe('Theme', () => {
const serialized = theme.toSerializedConfig();
expect(serialized.token?.colorPrimary).toBe('#ff0000');
expect(serialized.algorithm).toBe('dark');
expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
});
});
});

View File

@@ -17,7 +17,7 @@
* under the License.
*/
/* eslint-disable theme-colors/no-literal-colors */
import { SerializableThemeConfig } from './types';
import { type SerializableThemeConfig, ThemeAlgorithm } from './types';
const exampleThemes: Record<string, SerializableThemeConfig> = {
superset: {
@@ -27,11 +27,11 @@ const exampleThemes: Record<string, SerializableThemeConfig> = {
},
supersetDark: {
token: {},
algorithm: 'dark',
algorithm: ThemeAlgorithm.DARK,
},
supersetCompact: {
token: {},
algorithm: 'compact',
algorithm: ThemeAlgorithm.COMPACT,
},
funky: {
token: {
@@ -43,7 +43,7 @@ const exampleThemes: Record<string, SerializableThemeConfig> = {
borderRadius: 12,
fontFamily: 'Comic Sans MS, cursive',
},
algorithm: 'default',
algorithm: ThemeAlgorithm.DEFAULT,
},
funkyDark: {
token: {
@@ -55,7 +55,7 @@ const exampleThemes: Record<string, SerializableThemeConfig> = {
borderRadius: 12,
fontFamily: 'Comic Sans MS, cursive',
},
algorithm: 'dark',
algorithm: ThemeAlgorithm.DARK,
},
};
export default exampleThemes;

View File

@@ -16,17 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import emotionStyled from '@emotion/styled';
import emotionStyled, { CreateStyled } from '@emotion/styled';
import { useTheme as useThemeBasic } from '@emotion/react';
// import { theme as antdThemeImport } from 'antd';
import { Theme } from './Theme';
import type {
SupersetTheme,
SerializableThemeConfig,
AnyThemeConfig,
ThemeStorage,
ThemeControllerOptions,
ThemeContextType,
import {
type SupersetTheme,
type SerializableThemeConfig,
type AnyThemeConfig,
type ThemeStorage,
type ThemeControllerOptions,
type ThemeContextType,
ThemeAlgorithm,
} from './types';
export {
@@ -56,10 +56,12 @@ export function useTheme() {
return theme;
}
const styled = emotionStyled;
const styled: CreateStyled = emotionStyled;
// launching in in dark mode for now while iterating
const themeObject = Theme.fromConfig({ algorithm: 'default' });
const themeObject: Theme = Theme.fromConfig({
algorithm: ThemeAlgorithm.DEFAULT,
});
const { theme } = themeObject;
const supersetTheme = theme;

View File

@@ -33,6 +33,41 @@ import { Theme } from '.';
export type AntdTokens = ReturnType<typeof antdThemeImport.getDesignToken>;
export type AntdThemeConfig = ThemeConfig;
/**
* Theme algorithms supported by Antd.
* They can be used individually or in combination.
* - DEFAULT: Default light theme
* - DARK: Dark theme
* - COMPACT: Compact theme (smaller spacing)
*/
export enum ThemeAlgorithm {
DEFAULT = 'default',
DARK = 'dark',
COMPACT = 'compact',
}
/**
* Represents the current theme mode of the app.
* It can be one of the following:
* - DEFAULT: Light theme
* - DARK: Dark theme
* - SYSTEM: System theme (auto-detects based on system settings)
*/
export enum ThemeMode {
DEFAULT = 'default',
DARK = 'dark',
SYSTEM = 'system',
}
/**
* All valid algorithm values that can be used in theme config.
*/
export type ThemeAlgorithmOption =
| ThemeAlgorithm.DEFAULT
| ThemeAlgorithm.DARK
| ThemeAlgorithm.COMPACT
| ThemeAlgorithm[];
/**
* A serializable version of Ant Design's ThemeConfig
* Compatible with theme editor exports
@@ -40,11 +75,7 @@ export type AntdThemeConfig = ThemeConfig;
export type SerializableThemeConfig = {
token?: Record<string, any>;
components?: Record<string, any>;
algorithm?:
| 'default'
| 'dark'
| 'compact'
| ('default' | 'dark' | 'compact')[];
algorithm?: ThemeAlgorithmOption;
hashed?: boolean;
inherit?: boolean;
};
@@ -358,13 +389,6 @@ export type AllowedAntdTokenKeys = Extract<
keyof AntdTokens
>;
export enum ThemeMode {
LIGHT = 'light',
DARK = 'dark',
SYSTEM = 'system',
COMPACT = 'compact',
}
export type SharedAntdTokens = Pick<AntdTokens, AllowedAntdTokenKeys>;
/** The final shape for our custom theme object, combining old theme + shared antd + superset specifics. */
@@ -379,7 +403,7 @@ export interface ThemeStorage {
}
export interface ThemeControllerOptions {
themeObject: Theme;
themeObject?: Theme;
storage?: ThemeStorage;
storageKey?: string;
modeStorageKey?: string;
@@ -393,6 +417,6 @@ export interface ThemeContextType {
theme: Theme;
themeMode: ThemeMode;
setTheme: (config: AnyThemeConfig) => void;
changeThemeMode: (newMode: ThemeMode) => void;
setThemeMode: (newMode: ThemeMode) => void;
resetTheme: () => void;
}

View File

@@ -28,9 +28,10 @@ import {
genDeprecatedColorVariations,
} from './utils';
import {
AnyThemeConfig,
SerializableThemeConfig,
AntdThemeConfig,
type AnyThemeConfig,
type SerializableThemeConfig,
type AntdThemeConfig,
ThemeAlgorithm,
} from './types';
// Mock tinycolor2 for consistent testing
@@ -50,22 +51,25 @@ describe('Theme utilities', () => {
const config: AnyThemeConfig = {
token: { colorPrimary: '#ff0000' },
};
expect(isSerializableConfig(config)).toBe(true);
});
it('returns true when algorithm is a string', () => {
const config: AnyThemeConfig = {
token: { colorPrimary: '#ff0000' },
algorithm: 'dark',
algorithm: ThemeAlgorithm.DARK,
};
expect(isSerializableConfig(config)).toBe(true);
});
it('returns true when algorithm is an array of strings', () => {
const config: AnyThemeConfig = {
token: { colorPrimary: '#ff0000' },
algorithm: ['dark', 'compact'],
algorithm: [ThemeAlgorithm.DARK, ThemeAlgorithm.COMPACT],
};
expect(isSerializableConfig(config)).toBe(true);
});
@@ -74,15 +78,19 @@ describe('Theme utilities', () => {
token: { colorPrimary: '#ff0000' },
algorithm: antdThemeImport.darkAlgorithm,
};
expect(isSerializableConfig(config)).toBe(false);
});
it('returns false when algorithm is an array containing a function', () => {
const config: AnyThemeConfig = {
token: { colorPrimary: '#ff0000' },
// @ts-ignore
algorithm: [antdThemeImport.darkAlgorithm, 'compact'],
algorithm: [
antdThemeImport.darkAlgorithm,
antdThemeImport.compactAlgorithm,
],
};
expect(isSerializableConfig(config)).toBe(false);
});
});
@@ -91,18 +99,22 @@ describe('Theme utilities', () => {
it('converts string algorithm to function reference', () => {
const config: SerializableThemeConfig = {
token: { colorPrimary: '#ff0000' },
algorithm: 'dark',
algorithm: ThemeAlgorithm.DARK,
};
const result = deserializeThemeConfig(config);
expect(result.algorithm).toBe(antdThemeImport.darkAlgorithm);
});
it('converts array of string algorithms to function references', () => {
const config: SerializableThemeConfig = {
token: { colorPrimary: '#ff0000' },
algorithm: ['dark', 'compact'],
algorithm: [ThemeAlgorithm.DARK, ThemeAlgorithm.COMPACT],
};
const result = deserializeThemeConfig(config);
expect(Array.isArray(result.algorithm)).toBe(true);
expect(result.algorithm).toContain(antdThemeImport.darkAlgorithm);
expect(result.algorithm).toContain(antdThemeImport.compactAlgorithm);
@@ -111,10 +123,12 @@ describe('Theme utilities', () => {
it('preserves other configuration properties', () => {
const config: SerializableThemeConfig = {
token: { colorPrimary: '#ff0000' },
algorithm: 'dark',
algorithm: ThemeAlgorithm.DARK,
hashed: true,
};
const result = deserializeThemeConfig(config);
expect(result.token).toEqual({ colorPrimary: '#ff0000' });
expect(result.hashed).toBe(true);
});
@@ -123,25 +137,31 @@ describe('Theme utilities', () => {
const config: SerializableThemeConfig = {
token: { colorPrimary: '#ff0000' },
};
const result = deserializeThemeConfig(config);
expect(result.algorithm).toBeUndefined();
expect(result.algorithm).toBe(antdThemeImport.defaultAlgorithm);
});
it('converts default algorithm string to function reference', () => {
const config: SerializableThemeConfig = {
token: { colorPrimary: '#ff0000' },
algorithm: 'default',
algorithm: ThemeAlgorithm.DEFAULT,
};
const result = deserializeThemeConfig(config);
expect(result.algorithm).toBe(antdThemeImport.defaultAlgorithm);
});
it('converts compact algorithm string to function reference', () => {
const config: SerializableThemeConfig = {
token: { colorPrimary: '#ff0000' },
algorithm: 'compact',
algorithm: ThemeAlgorithm.COMPACT,
};
const result = deserializeThemeConfig(config);
expect(result.algorithm).toBe(antdThemeImport.compactAlgorithm);
});
});
@@ -152,8 +172,10 @@ describe('Theme utilities', () => {
token: { colorPrimary: '#ff0000' },
algorithm: antdThemeImport.darkAlgorithm,
};
const result = serializeThemeConfig(config);
expect(result.algorithm).toBe('dark');
expect(result.algorithm).toBe(ThemeAlgorithm.DARK);
});
it('converts array of function algorithms to strings', () => {
@@ -164,10 +186,12 @@ describe('Theme utilities', () => {
antdThemeImport.compactAlgorithm,
],
};
const result = serializeThemeConfig(config);
expect(Array.isArray(result.algorithm)).toBe(true);
expect(result.algorithm).toContain('dark');
expect(result.algorithm).toContain('compact');
expect(result.algorithm).toContain(ThemeAlgorithm.DARK);
expect(result.algorithm).toContain(ThemeAlgorithm.COMPACT);
});
it('preserves other configuration properties', () => {
@@ -176,7 +200,9 @@ describe('Theme utilities', () => {
algorithm: antdThemeImport.darkAlgorithm,
hashed: true,
};
const result = serializeThemeConfig(config);
expect(result.token).toEqual({ colorPrimary: '#ff0000' });
expect(result.hashed).toBe(true);
});
@@ -185,7 +211,9 @@ describe('Theme utilities', () => {
const config: AntdThemeConfig = {
token: { colorPrimary: '#ff0000' },
};
const result = serializeThemeConfig(config);
expect(result.algorithm).toBeUndefined();
});
@@ -196,8 +224,10 @@ describe('Theme utilities', () => {
// @ts-ignore
algorithm: unknownAlgorithm,
};
const result = serializeThemeConfig(config);
expect(result.algorithm).toBe('default');
expect(result.algorithm).toBe(ThemeAlgorithm.DEFAULT);
});
it('converts default algorithm function to string', () => {
@@ -205,8 +235,10 @@ describe('Theme utilities', () => {
token: { colorPrimary: '#ff0000' },
algorithm: antdThemeImport.defaultAlgorithm,
};
const result = serializeThemeConfig(config);
expect(result.algorithm).toBe('default');
expect(result.algorithm).toBe(ThemeAlgorithm.DEFAULT);
});
it('converts compact algorithm function to string', () => {
@@ -214,8 +246,10 @@ describe('Theme utilities', () => {
token: { colorPrimary: '#ff0000' },
algorithm: antdThemeImport.compactAlgorithm,
};
const result = serializeThemeConfig(config);
expect(result.algorithm).toBe('compact');
expect(result.algorithm).toBe(ThemeAlgorithm.COMPACT);
});
it('defaults each unknown algorithm in array to "default"', () => {
@@ -225,9 +259,14 @@ describe('Theme utilities', () => {
// @ts-ignore
algorithm: [antdThemeImport.darkAlgorithm, unknownAlgorithm],
};
const result = serializeThemeConfig(config);
expect(Array.isArray(result.algorithm)).toBe(true);
expect(result.algorithm).toEqual(['dark', 'default']);
expect(result.algorithm).toEqual([
ThemeAlgorithm.DARK,
ThemeAlgorithm.DEFAULT,
]);
});
it('handles mixed known and unknown algorithms in array', () => {
@@ -244,13 +283,15 @@ describe('Theme utilities', () => {
unknownAlgorithm2,
],
};
const result = serializeThemeConfig(config);
expect(Array.isArray(result.algorithm)).toBe(true);
expect(result.algorithm).toEqual([
'dark',
'default',
'compact',
'default',
ThemeAlgorithm.DARK,
ThemeAlgorithm.DEFAULT,
ThemeAlgorithm.COMPACT,
ThemeAlgorithm.DEFAULT,
]);
});
});
@@ -261,16 +302,20 @@ describe('Theme utilities', () => {
token: { colorPrimary: '#ff0000' },
algorithm: antdThemeImport.darkAlgorithm,
};
const result = normalizeThemeConfig(config);
expect(result).toBe(config);
});
it('deserializes serializable configs', () => {
const config: SerializableThemeConfig = {
token: { colorPrimary: '#ff0000' },
algorithm: 'dark',
algorithm: ThemeAlgorithm.DARK,
};
const result = normalizeThemeConfig(config);
expect(result.algorithm).toBe(antdThemeImport.darkAlgorithm);
});
});
@@ -278,14 +323,18 @@ describe('Theme utilities', () => {
describe('getAntdConfig', () => {
it('returns config with default algorithm for light mode', () => {
const seed = { colorPrimary: '#ff0000' };
const result = getAntdConfig(seed, false);
expect(result.token).toBe(seed);
expect(result.algorithm).toBe(antdThemeImport.defaultAlgorithm);
});
it('returns config with dark algorithm for dark mode', () => {
const seed = { colorPrimary: '#ff0000' };
const result = getAntdConfig(seed, true);
expect(result.token).toBe(seed);
expect(result.algorithm).toBe(antdThemeImport.darkAlgorithm);
});
@@ -301,7 +350,9 @@ describe('Theme utilities', () => {
colorInfo: '#info',
otherToken: 'ignore-me',
};
const result = getSystemColors(tokens);
expect(result).toEqual({
colorPrimary: '#primary',
colorError: '#error',
@@ -315,6 +366,7 @@ describe('Theme utilities', () => {
describe('genDeprecatedColorVariations', () => {
it('generates color variations for light mode', () => {
const result = genDeprecatedColorVariations('#base-color', false);
expect(result.base).toBe('#base-color');
expect(result.light1).toBe('#mixed-color');
expect(result.dark1).toBe('#mixed-color');
@@ -322,6 +374,7 @@ describe('Theme utilities', () => {
it('generates color variations for dark mode', () => {
const result = genDeprecatedColorVariations('#base-color', true);
expect(result.base).toBe('#base-color');
expect(result.light1).toBe('#mixed-color');
expect(result.dark1).toBe('#mixed-color');
@@ -337,7 +390,9 @@ describe('Theme utilities', () => {
colorSuccess: '#success',
colorInfo: '#info',
};
const result = getDeprecatedColors(systemColors, false);
expect(result.primary.base).toBe('#primary');
expect(result.error.base).toBe('#error');
expect(result.warning.base).toBe('#warning');

View File

@@ -19,13 +19,14 @@
import { theme as antdThemeImport } from 'antd';
import tinycolor from 'tinycolor2';
import {
AntdThemeConfig,
AnyThemeConfig,
SerializableThemeConfig,
DeprecatedColorVariations,
DeprecatedThemeColors,
SystemColors,
SupersetTheme,
type AntdThemeConfig,
type AnyThemeConfig,
type SerializableThemeConfig,
type DeprecatedColorVariations,
type DeprecatedThemeColors,
type SystemColors,
type SupersetTheme,
ThemeAlgorithm,
} from './types';
/**
@@ -38,9 +39,8 @@ export function isSerializableConfig(
if (algorithm === undefined) return true;
if (Array.isArray(algorithm)) {
if (Array.isArray(algorithm))
return (algorithm as unknown[]).every(alg => typeof alg === 'string');
}
return typeof algorithm === 'string';
}
@@ -60,9 +60,21 @@ export function deserializeThemeConfig(
let resolvedAlgorithm;
if (Array.isArray(algorithm)) {
resolvedAlgorithm = algorithm.map(alg => algorithmMap[alg]);
} else if (algorithm) {
const validAlgorithms = algorithm
.map((alg: ThemeAlgorithm) => algorithmMap[alg])
.filter(Boolean);
// If we have valid algorithms, use them; otherwise fallback to default
if (validAlgorithms.length > 0) {
resolvedAlgorithm =
validAlgorithms.length === 1 ? validAlgorithms[0] : validAlgorithms;
} else {
resolvedAlgorithm = antdThemeImport.defaultAlgorithm;
}
} else if (algorithm && algorithmMap[algorithm]) {
resolvedAlgorithm = algorithmMap[algorithm];
} else {
resolvedAlgorithm = antdThemeImport.defaultAlgorithm;
}
return {
@@ -83,19 +95,21 @@ export function serializeThemeConfig(
if (Array.isArray(algorithm)) {
serializedAlgorithm = algorithm.map(alg => {
if (alg === antdThemeImport.defaultAlgorithm) return 'default';
if (alg === antdThemeImport.darkAlgorithm) return 'dark';
if (alg === antdThemeImport.compactAlgorithm) return 'compact';
return 'default'; // Fallback
}) as ('default' | 'dark' | 'compact')[];
if (alg === antdThemeImport.defaultAlgorithm)
return ThemeAlgorithm.DEFAULT;
if (alg === antdThemeImport.darkAlgorithm) return ThemeAlgorithm.DARK;
if (alg === antdThemeImport.compactAlgorithm)
return ThemeAlgorithm.COMPACT;
return ThemeAlgorithm.DEFAULT; // Fallback
}) as ThemeAlgorithm[];
} else if (algorithm) {
if (algorithm === antdThemeImport.defaultAlgorithm)
serializedAlgorithm = 'default';
serializedAlgorithm = ThemeAlgorithm.DEFAULT;
else if (algorithm === antdThemeImport.darkAlgorithm)
serializedAlgorithm = 'dark';
serializedAlgorithm = ThemeAlgorithm.DARK;
else if (algorithm === antdThemeImport.compactAlgorithm)
serializedAlgorithm = 'compact';
else serializedAlgorithm = 'default'; // Fallback
serializedAlgorithm = ThemeAlgorithm.COMPACT;
else serializedAlgorithm = ThemeAlgorithm.DEFAULT; // Fallback
}
return {
@@ -109,9 +123,8 @@ export function serializeThemeConfig(
* This automatically detects and converts serializable configs
*/
export function normalizeThemeConfig(config: AnyThemeConfig): AntdThemeConfig {
if (isSerializableConfig(config)) {
return deserializeThemeConfig(config);
}
if (isSerializableConfig(config)) return deserializeThemeConfig(config);
return config as AntdThemeConfig;
}
@@ -164,7 +177,7 @@ export function getDeprecatedColors(
systemColors: SystemColors,
isDark: boolean,
): DeprecatedThemeColors {
const sc = systemColors;
const sc: SystemColors = systemColors;
return {
primary: genDeprecatedColorVariations(sc.colorPrimary, isDark),
error: genDeprecatedColorVariations(sc.colorError, isDark),

View File

@@ -86,20 +86,33 @@ export default class Translator {
}
translate(input: string, ...args: unknown[]): string {
return this.i18n.translate(input).fetch(...args);
try {
return this.i18n.translate(input).fetch(...args);
} catch (err) {
logging.warn(`Translation failed for key "${input}" with args:`, args);
return input;
}
}
translateWithNumber(key: string, ...args: unknown[]): string {
const [plural, num, ...rest] = args;
if (typeof plural === 'number') {
try {
const [plural, num, ...rest] = args;
if (typeof plural === 'number') {
return this.i18n
.translate(key)
.ifPlural(plural, key)
.fetch(plural, num, ...rest);
}
return this.i18n
.translate(key)
.ifPlural(plural, key)
.fetch(plural, num, ...args);
.ifPlural(num as number, plural as string)
.fetch(...rest);
} catch (err) {
logging.warn(
`Plural translation failed for key "${key}" with args:`,
args,
);
}
return this.i18n
.translate(key)
.ifPlural(num as number, plural as string)
.fetch(...rest);
return key;
}
}

View File

@@ -45,6 +45,7 @@ export enum FeatureFlag {
EnableTemplateProcessing = 'ENABLE_TEMPLATE_PROCESSING',
EscapeMarkdownHtml = 'ESCAPE_MARKDOWN_HTML',
EstimateQueryCost = 'ESTIMATE_QUERY_COST',
FilterBarClosedByDefault = 'FILTERBAR_CLOSED_BY_DEFAULT',
GlobalAsyncQueries = 'GLOBAL_ASYNC_QUERIES',
ListviewsDefaultCardView = 'LISTVIEWS_DEFAULT_CARD_VIEW',
ScheduledQueries = 'SCHEDULED_QUERIES',

View File

@@ -19,28 +19,18 @@
import { t } from '../translation';
const VALIDE_OSM_URLS = ['https://tile.osm', 'https://tile.openstreetmap'];
/**
* Validate a [Mapbox styles URL](https://docs.mapbox.com/help/glossary/style-url/)
* or a title server URL.
* @param v
*/
export default function validateMapboxStylesUrl(v: unknown) {
if (typeof v === 'string') {
const trimmed_v = v.trim();
if (
typeof v === 'string' &&
trimmed_v.length > 0 &&
(trimmed_v.startsWith('mapbox://styles/') ||
trimmed_v.startsWith('tile://http') ||
VALIDE_OSM_URLS.some(s => trimmed_v.startsWith(s)))
) {
return false;
}
if (
typeof v === 'string' &&
v.trim().length > 0 &&
v.trim().startsWith('mapbox://styles/')
) {
return false;
}
return t(
'is expected to be a Mapbox/OSM URL (eg. mapbox://styles/...) or a tile server URL (eg. tile://http...)',
);
return t('is expected to be a Mapbox URL');
}

View File

@@ -29,11 +29,6 @@ describe('validateMapboxStylesUrl', () => {
'mapbox://styles/foobar/clp2dr5r4008a01pcg4ad45m8',
),
).toEqual(false);
expect(
validateMapboxStylesUrl(
'tile://https://c.tile.openstreetmap.org/{z}/{x}/{y}.png',
),
).toEqual(false);
});
[
@@ -45,7 +40,7 @@ describe('validateMapboxStylesUrl', () => {
].forEach(value => {
it(`should not validate ${value}`, () => {
expect(validateMapboxStylesUrl(value)).toEqual(
'is expected to be a Mapbox/OSM URL (eg. mapbox://styles/...) or a tile server URL (eg. tile://http...)',
'is expected to be a Mapbox URL',
);
});
});

View File

@@ -24,11 +24,10 @@
"lib"
],
"dependencies": {
"@deck.gl/aggregation-layers": "^9.1.12",
"@deck.gl/core": "^9.1.12",
"@deck.gl/geo-layers": "^9.1.12",
"@deck.gl/layers": "^9.1.12",
"@deck.gl/react": "^9.1.12",
"@deck.gl/aggregation-layers": "^9.0.38",
"@deck.gl/core": "^9.0.37",
"@deck.gl/layers": "^9.0.38",
"@deck.gl/react": "^9.1.4",
"@mapbox/geojson-extent": "^1.0.1",
"@math.gl/web-mercator": "^4.1.0",
"@types/d3-array": "^2.0.0",

View File

@@ -44,9 +44,12 @@ import {
DeckGLContainerHandle,
DeckGLContainerStyledWrapper,
} from './DeckGLContainer';
import { Point } from './types';
import { GetLayerType } from './factory';
import { ColorBreakpointType, ColorType, Point } from './types';
import { TooltipProps } from './components/Tooltip';
import { COLOR_SCHEME_TYPES, ColorSchemeType } from './utilities/utils';
import { getColorBreakpointsBuckets } from './utils';
import { DEFAULT_DECKGL_COLOR } from './utilities/Shared_DeckGL';
const { getScale } = CategoricalColorNamespace;
@@ -55,18 +58,24 @@ function getCategories(fd: QueryFormData, data: JsonObject[]) {
const fixedColor = [c.r, c.g, c.b, 255 * c.a];
const appliedScheme = fd.color_scheme;
const colorFn = getScale(appliedScheme);
const categories: Record<any, { color: any; enabled: boolean }> = {};
data.forEach(d => {
if (d.cat_color != null && !categories.hasOwnProperty(d.cat_color)) {
let color;
if (fd.dimension) {
color = hexToRGB(colorFn(d.cat_color, fd.sliceId), c.a * 255);
} else {
color = fixedColor;
let categories: Record<any, { color: any; enabled: boolean }> = {};
const colorSchemeType = fd.color_scheme_type;
if (colorSchemeType === COLOR_SCHEME_TYPES.color_breakpoints) {
categories = getColorBreakpointsBuckets(fd.color_breakpoints);
} else {
data.forEach(d => {
if (d.cat_color != null && !categories.hasOwnProperty(d.cat_color)) {
let color;
if (fd.dimension) {
color = hexToRGB(colorFn(d.cat_color, fd.sliceId), c.a * 255);
} else {
color = fixedColor;
}
categories[d.cat_color] = { color, enabled: true };
}
categories[d.cat_color] = { color, enabled: true };
}
});
});
}
return categories;
}
@@ -133,22 +142,73 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
}
}, []);
const addColor = useCallback((data: JsonObject[], fd: QueryFormData) => {
const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
const appliedScheme = fd.color_scheme;
const colorFn = getScale(appliedScheme);
const addColor = useCallback(
(
data: JsonObject[],
fd: QueryFormData,
selectedColorScheme: ColorSchemeType,
) => {
const appliedScheme = fd.color_scheme;
const colorFn = getScale(appliedScheme);
let color: ColorType;
return data.map(d => {
let color;
if (fd.dimension) {
color = hexToRGB(colorFn(d.cat_color, fd.slice_id), c.a * 255);
switch (selectedColorScheme) {
case COLOR_SCHEME_TYPES.fixed_color: {
color = fd.color_picker || { r: 0, g: 0, b: 0, a: 100 };
return { ...d, color };
return data.map(d => ({
...d,
color: [color.r, color.g, color.b, color.a * 255],
}));
}
case COLOR_SCHEME_TYPES.categorical_palette: {
return data.map(d => ({
...d,
color: hexToRGB(colorFn(d.cat_color, fd.slice_id)),
}));
}
case COLOR_SCHEME_TYPES.color_breakpoints: {
const defaultBreakpointColor = fd.deafult_breakpoint_color
? [
fd.deafult_breakpoint_color.r,
fd.deafult_breakpoint_color.g,
fd.deafult_breakpoint_color.b,
fd.deafult_breakpoint_color.a * 255,
]
: [
DEFAULT_DECKGL_COLOR.r,
DEFAULT_DECKGL_COLOR.g,
DEFAULT_DECKGL_COLOR.b,
DEFAULT_DECKGL_COLOR.a * 255,
];
return data.map(d => {
const breakpointForPoint: ColorBreakpointType =
fd.color_breakpoints?.find(
(breakpoint: ColorBreakpointType) =>
d.metric >= breakpoint.minValue &&
d.metric <= breakpoint.maxValue,
);
return {
...d,
color: breakpointForPoint
? [
breakpointForPoint?.color.r,
breakpointForPoint?.color.g,
breakpointForPoint?.color.b,
breakpointForPoint?.color.a * 255,
]
: defaultBreakpointColor,
};
});
}
default: {
return [];
}
}
return d;
});
}, []);
},
[],
);
const getLayers = useCallback(() => {
const {
@@ -163,8 +223,10 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
} = props;
let features = payload.data.features ? [...payload.data.features] : [];
const selectedColorScheme = fd.color_scheme_type;
// Add colors from categories or fixed color
features = addColor(features, fd);
features = addColor(features, fd, selectedColorScheme);
// Apply user defined data mutator if defined
if (fd.js_data_mutator) {

View File

@@ -39,12 +39,6 @@ import { JsonObject, JsonValue, styled, usePrevious } from '@superset-ui/core';
import Tooltip, { TooltipProps } from './components/Tooltip';
import 'mapbox-gl/dist/mapbox-gl.css';
import { Viewport } from './utils/fitViewport';
import {
MAPBOX_LAYER_PREFIX,
OSM_LAYER_KEYWORDS,
TILE_LAYER_PREFIX,
buildTileLayer,
} from './utils';
const TICK = 250; // milliseconds
@@ -108,20 +102,6 @@ export const DeckGLContainer = memo(
);
const layers = useCallback(() => {
if (
(props.mapStyle?.startsWith(TILE_LAYER_PREFIX) ||
OSM_LAYER_KEYWORDS.some(tilek => props.mapStyle?.includes(tilek))) &&
props.layers.some(
l => typeof l !== 'function' && l?.id === 'tile-layer',
) === false
) {
props.layers.unshift(
buildTileLayer(
(props.mapStyle ?? '').replace(TILE_LAYER_PREFIX, ''),
'tile-layer',
),
);
}
// Support for layer factory
if (props.layers.some(l => typeof l === 'function')) {
return props.layers.map(l =>
@@ -130,7 +110,7 @@ export const DeckGLContainer = memo(
}
return props.layers as Layer[];
}, [props.layers, props.mapStyle]);
}, [props.layers]);
const { children = null, height, width } = props;
@@ -154,13 +134,11 @@ export const DeckGLContainer = memo(
glContextRef.current = context.gl;
}}
>
{props.mapStyle?.startsWith(MAPBOX_LAYER_PREFIX) && (
<StaticMap
preserveDrawingBuffer
mapStyle={props.mapStyle || 'light'}
mapboxApiAccessToken={props.mapboxApiAccessToken}
/>
)}
<StaticMap
preserveDrawingBuffer
mapStyle={props.mapStyle || 'light'}
mapboxApiAccessToken={props.mapboxApiAccessToken}
/>
</DeckGL>
{children}
</div>

View File

@@ -21,6 +21,7 @@
*/
import { memo } from 'react';
import { formatNumber, styled } from '@superset-ui/core';
import { Color } from '@deck.gl/core';
const StyledLegend = styled.div`
${({ theme }) => `
@@ -59,7 +60,7 @@ export type LegendProps = {
format: string | null;
forceCategorical?: boolean;
position?: null | 'tl' | 'tr' | 'bl' | 'br';
categories: Record<string, { enabled: boolean; color: number[] | undefined }>;
categories: Record<string, { enabled: boolean; color: Color | undefined }>;
toggleCategory?: (key: string) => void;
showSingleCategory?: (key: string) => void;
};

View File

@@ -40,6 +40,8 @@ import CategoricalDeckGLContainer from './CategoricalDeckGLContainer';
import fitViewport, { Viewport } from './utils/fitViewport';
import { Point } from './types';
import { TooltipProps } from './components/Tooltip';
import { getColorBreakpointsBuckets } from './utils';
import Legend from './components/Legend';
type DeckGLComponentProps = {
datasource: Datasource;
@@ -103,6 +105,9 @@ export function createDeckGLComponent(
}
return props.viewport;
};
const [categories, setCategories] = useState<JsonObject>(
getColorBreakpointsBuckets(props.formData.color_breakpoints) || [],
);
const [viewport, setViewport] = useState(getAdjustedViewport());
@@ -139,6 +144,14 @@ export function createDeckGLComponent(
[setTooltip],
);
useEffect(() => {
const categories = getColorBreakpointsBuckets(
props.formData.color_breakpoints,
);
setCategories(categories);
}, [props]);
const [layer, setLayer] = useState(computeLayer(props));
useEffect(() => {
@@ -161,17 +174,25 @@ export function createDeckGLComponent(
const { formData, payload, setControlValue, height, width } = props;
return (
<DeckGLContainerStyledWrapper
ref={containerRef}
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport}
layers={[layer]}
mapStyle={formData.mapbox_style}
setControlValue={setControlValue}
width={width}
height={height}
onViewportChange={setViewport}
/>
<div style={{ position: 'relative' }}>
<DeckGLContainerStyledWrapper
ref={containerRef}
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport}
layers={[layer]}
mapStyle={formData.mapbox_style}
setControlValue={setControlValue}
width={width}
height={height}
onViewportChange={setViewport}
/>
<Legend
forceCategorical
categories={categories}
format={props.formData.legend_format}
position={props.formData.legend_position}
/>
</div>
);
});
}

View File

@@ -18,6 +18,7 @@
*/
import { ArcLayer } from '@deck.gl/layers';
import { JsonObject, QueryFormData, t } from '@superset-ui/core';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
import { commonLayerProps } from '../common';
import { GetLayerType, createCategoricalDeckGLComponent } from '../../factory';
import TooltipRow from '../../TooltipRow';
@@ -68,12 +69,24 @@ export const getLayer: GetLayerType<ArcLayer> = function ({
const sc = fd.color_picker;
const tc = fd.target_color_picker;
const colorSchemeType = fd.color_scheme_type;
return new ArcLayer({
data,
getSourceColor: (d: any) =>
d.sourceColor || d.color || [sc.r, sc.g, sc.b, 255 * sc.a],
getTargetColor: (d: any) =>
d.targetColor || d.color || [tc.r, tc.g, tc.b, 255 * tc.a],
getSourceColor: (d: any) => {
if (colorSchemeType === COLOR_SCHEME_TYPES.fixed_color) {
return [sc.r, sc.g, sc.b, 255 * sc.a];
}
return d.targetColor || d.color;
},
getTargetColor: (d: any) => {
if (colorSchemeType === COLOR_SCHEME_TYPES.fixed_color) {
return [tc.r, tc.g, tc.b, 255 * tc.a];
}
return d.targetColor || d.color;
},
id: `path-layer-${fd.slice_id}` as const,
getWidth: fd.stroke_width ? fd.stroke_width : 3,
...commonLayerProps({

View File

@@ -22,11 +22,14 @@ import timeGrainSqlaAnimationOverrides, {
columnChoices,
PRIMARY_COLOR,
} from '../../utilities/controls';
import { formatSelectOptions } from '../../utilities/utils';
import {
COLOR_SCHEME_TYPES,
formatSelectOptions,
isColorSchemeTypeVisible,
} from '../../utilities/utils';
import {
filterNulls,
autozoom,
dimension,
jsColumns,
jsDataMutator,
jsTooltip,
@@ -35,6 +38,9 @@ import {
legendPosition,
viewport,
mapboxStyle,
deckGLCategoricalColor,
deckGLCategoricalColorSchemeSelect,
deckGLCategoricalColorSchemeTypeSelect,
} from '../../utilities/Shared_DeckGL';
const config: ControlPanelConfig = {
@@ -81,7 +87,37 @@ const config: ControlPanelConfig = {
label: t('Arc'),
controlSetRows: [
[
'color_picker',
{
name: 'color_scheme_type',
config: {
...deckGLCategoricalColorSchemeTypeSelect.config,
choices: [
[COLOR_SCHEME_TYPES.fixed_color, t('Fixed color')],
[
COLOR_SCHEME_TYPES.categorical_palette,
t('Categorical palette'),
],
],
default: COLOR_SCHEME_TYPES.fixed_color,
},
},
],
[
{
name: 'color_picker',
config: {
label: t('Source Color'),
description: t('Color of the source location'),
type: 'ColorPickerControl',
default: PRIMARY_COLOR,
renderTrigger: true,
visibility: ({ controls }) =>
isColorSchemeTypeVisible(
controls,
COLOR_SCHEME_TYPES.fixed_color,
),
},
},
{
name: 'target_color_picker',
config: {
@@ -90,22 +126,16 @@ const config: ControlPanelConfig = {
type: 'ColorPickerControl',
default: PRIMARY_COLOR,
renderTrigger: true,
visibility: ({ controls }) =>
isColorSchemeTypeVisible(
controls,
COLOR_SCHEME_TYPES.fixed_color,
),
},
},
],
[
{
name: dimension.name,
config: {
...dimension.config,
label: t('Categorical Color'),
description: t(
'Pick a dimension from which categorical colors are defined',
),
},
},
'color_scheme',
],
[deckGLCategoricalColor],
[deckGLCategoricalColorSchemeSelect],
[
{
name: 'stroke_width',
@@ -119,9 +149,9 @@ const config: ControlPanelConfig = {
choices: formatSelectOptions([1, 2, 3, 4, 5]),
},
},
legendPosition,
],
[legendFormat, null],
[legendPosition],
[legendFormat],
],
},
{

View File

@@ -16,15 +16,19 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Color } from '@deck.gl/core';
import { GridLayer } from '@deck.gl/aggregation-layers';
import { t, CategoricalColorNamespace, JsonObject } from '@superset-ui/core';
import { commonLayerProps, getAggFunc } from '../common';
import {
commonLayerProps,
getAggFunc,
getColorForBreakpoints,
getColorRange,
} from '../common';
import sandboxedEval from '../../utils/sandbox';
import { hexToRGB } from '../../utils/colors';
import { createDeckGLComponent, GetLayerType } from '../../factory';
import TooltipRow from '../../TooltipRow';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
function setTooltipContent(o: JsonObject) {
return (
@@ -55,9 +59,6 @@ export const getLayer: GetLayerType<GridLayer> = function ({
const fd = formData;
const appliedScheme = fd.color_scheme;
const colorScale = CategoricalColorNamespace.getScale(appliedScheme);
const colorRange = colorScale
.range()
.map(color => hexToRGB(color)) as Color[];
let data = payload.data.features;
if (fd.js_data_mutator) {
@@ -66,19 +67,39 @@ export const getLayer: GetLayerType<GridLayer> = function ({
data = jsFnMutator(data);
}
const colorBreakpoints = fd.color_breakpoints;
const colorSchemeType = fd.color_scheme_type;
const colorRange = getColorRange({
defaultBreakpointsColor: fd.deafult_breakpoint_color,
colorSchemeType,
colorScale,
colorBreakpoints,
fixedColor: fd.color_picker,
});
const aggFunc = getAggFunc(fd.js_agg_function, p => p.weight);
const colorAggFunc =
colorSchemeType === COLOR_SCHEME_TYPES.color_breakpoints
? (p: number[]) => getColorForBreakpoints(aggFunc, p, colorBreakpoints)
: aggFunc;
return new GridLayer({
id: `grid-layer-${fd.slice_id}` as const,
id: `grid-layer-${fd.slice_id}-${JSON.stringify(colorBreakpoints)}` as const,
data,
cellSize: fd.grid_size,
extruded: fd.extruded,
colorDomain:
colorSchemeType === COLOR_SCHEME_TYPES.color_breakpoints && colorRange
? [0, colorRange.length]
: undefined,
colorRange,
outline: false,
// @ts-ignore
getElevationValue: aggFunc,
// @ts-ignore
getColorValue: aggFunc,
getColorValue: colorAggFunc,
...commonLayerProps({
formData: fd,
setDataMask,

View File

@@ -33,7 +33,10 @@ import {
viewport,
spatial,
mapboxStyle,
legendPosition,
generateDeckGLColorSchemeControls,
} from '../../utilities/Shared_DeckGL';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -53,7 +56,11 @@ const config: ControlPanelConfig = {
controlSetRows: [
[mapboxStyle],
[viewport],
['color_scheme'],
...generateDeckGLColorSchemeControls({
defaultSchemeType: COLOR_SCHEME_TYPES.categorical_palette,
disableCategoricalColumn: true,
}),
[legendPosition],
[autozoom],
[gridSize],
[extruded],

View File

@@ -17,11 +17,10 @@
* under the License.
*/
import { HeatmapLayer } from '@deck.gl/aggregation-layers';
import { Position, Color } from '@deck.gl/core';
import { Position } from '@deck.gl/core';
import { t, getSequentialSchemeRegistry, JsonObject } from '@superset-ui/core';
import { commonLayerProps } from '../common';
import { commonLayerProps, getColorRange } from '../common';
import sandboxedEval from '../../utils/sandbox';
import { hexToRGB } from '../../utils/colors';
import { GetLayerType, createDeckGLComponent } from '../../factory';
import TooltipRow from '../../TooltipRow';
@@ -63,10 +62,15 @@ export const getLayer: GetLayerType<HeatmapLayer> = ({
const colorScale = getSequentialSchemeRegistry()
?.get(colorScheme)
?.createLinearScale([0, 6]);
const colorRange = colorScale
?.range()
?.map(color => hexToRGB(color))
?.reverse() as Color[];
const colorSchemeType = fd.color_scheme_type;
const colorRange = getColorRange({
defaultBreakpointsColor: fd.deafult_breakpoint_color,
colorBreakpoints: fd.color_breakpoints,
fixedColor: fd.color_picker,
colorSchemeType,
colorScale,
})?.reverse();
return new HeatmapLayer({
id: `heatmap-layer-${fd.slice_id}` as const,

View File

@@ -28,6 +28,9 @@ import {
} from '@superset-ui/core';
import {
autozoom,
deckGLCategoricalColorSchemeTypeSelect,
deckGLFixedColor,
deckGLLinearColorSchemeSelect,
filterNulls,
jsColumns,
jsDataMutator,
@@ -37,6 +40,7 @@ import {
spatial,
viewport,
} from '../../utilities/Shared_DeckGL';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
const INTENSITY_OPTIONS = Array.from(
{ length: 10 },
@@ -99,7 +103,21 @@ const config: ControlPanelConfig = {
controlSetRows: [
[mapboxStyle],
[viewport],
['linear_color_scheme'],
[
{
name: 'color_scheme_type',
config: {
...deckGLCategoricalColorSchemeTypeSelect.config,
choices: [
[COLOR_SCHEME_TYPES.fixed_color, t('Fixed color')],
[COLOR_SCHEME_TYPES.linear_palette, t('Linear palette')],
],
default: COLOR_SCHEME_TYPES.linear_palette,
},
},
],
[deckGLFixedColor],
[deckGLLinearColorSchemeSelect],
[autozoom],
[
{

View File

@@ -16,13 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Color } from '@deck.gl/core';
import { HexagonLayer } from '@deck.gl/aggregation-layers';
import { t, CategoricalColorNamespace, JsonObject } from '@superset-ui/core';
import { commonLayerProps, getAggFunc } from '../common';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
import {
commonLayerProps,
getAggFunc,
getColorForBreakpoints,
getColorRange,
} from '../common';
import sandboxedEval from '../../utils/sandbox';
import { hexToRGB } from '../../utils/colors';
import { GetLayerType, createDeckGLComponent } from '../../factory';
import TooltipRow from '../../TooltipRow';
@@ -54,9 +58,6 @@ export const getLayer: GetLayerType<HexagonLayer> = function ({
const fd = formData;
const appliedScheme = fd.color_scheme;
const colorScale = CategoricalColorNamespace.getScale(appliedScheme);
const colorRange = colorScale
.range()
.map(color => hexToRGB(color)) as Color[];
let data = payload.data.features;
if (fd.js_data_mutator) {
@@ -64,19 +65,40 @@ export const getLayer: GetLayerType<HexagonLayer> = function ({
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
data = jsFnMutator(data);
}
const aggFunc = getAggFunc(fd.js_agg_function, p => p?.weight);
const colorSchemeType = fd.color_scheme_type;
const colorRange = getColorRange({
defaultBreakpointsColor: fd.deafult_breakpoint_color,
colorBreakpoints: fd.color_breakpoints,
fixedColor: fd.color_picker,
colorSchemeType,
colorScale,
});
const colorBreakpoints = fd.color_breakpoints;
const aggFunc = getAggFunc(fd.js_agg_function, p => p.weight);
const colorAggFunc =
colorSchemeType === COLOR_SCHEME_TYPES.color_breakpoints
? (p: number[]) => getColorForBreakpoints(aggFunc, p, colorBreakpoints)
: aggFunc;
return new HexagonLayer({
id: `hex-layer-${fd.slice_id}` as const,
id: `hex-layer-${fd.slice_id}-${JSON.stringify(colorBreakpoints)}` as const,
data,
radius: fd.grid_size,
extruded: fd.extruded,
colorDomain:
colorSchemeType === COLOR_SCHEME_TYPES.color_breakpoints && colorRange
? [0, colorRange.length]
: undefined,
colorRange,
outline: false,
// @ts-ignore
getElevationValue: aggFunc,
// @ts-ignore
getColorValue: aggFunc,
getColorValue: colorAggFunc,
...commonLayerProps({
formData: fd,
setTooltip,

View File

@@ -25,6 +25,7 @@ import {
autozoom,
extruded,
filterNulls,
generateDeckGLColorSchemeControls,
gridSize,
jsColumns,
jsDataMutator,
@@ -52,7 +53,8 @@ const config: ControlPanelConfig = {
label: t('Map'),
controlSetRows: [
[mapboxStyle],
['color_scheme', viewport],
...generateDeckGLColorSchemeControls({}),
[viewport],
[autozoom],
[gridSize],
[extruded],

View File

@@ -36,11 +36,16 @@ import {
import { PolygonLayer } from '@deck.gl/layers';
import { Color } from '@deck.gl/core';
import Legend from '../../components/Legend';
import TooltipRow from '../../TooltipRow';
import { getBuckets, getBreakPointColorScaler } from '../../utils';
import {
getBuckets,
getBreakPointColorScaler,
getColorBreakpointsBuckets,
} from '../../utils';
import { commonLayerProps } from '../common';
import { commonLayerProps, getColorForBreakpoints } from '../common';
import sandboxedEval from '../../utils/sandbox';
import getPointsFromPolygon from '../../utils/getPointsFromPolygon';
import fitViewport, { Viewport } from '../../utils/fitViewport';
@@ -50,6 +55,8 @@ import {
} from '../../DeckGLContainer';
import { TooltipProps } from '../../components/Tooltip';
import { GetLayerType } from '../../factory';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
import { DEFAULT_DECKGL_COLOR } from '../../utilities/Shared_DeckGL';
const DOUBLE_CLICK_THRESHOLD = 250; // milliseconds
@@ -107,8 +114,11 @@ export const getLayer: GetLayerType<PolygonLayer> = function ({
emitCrossFilters,
}) {
const fd = formData as PolygonFormData;
const fc = fd.fill_color_picker;
const sc = fd.stroke_color_picker;
const fc: { r: number; g: number; b: number; a: number } =
fd.fill_color_picker;
const sc: { r: number; g: number; b: number; a: number } =
fd.stroke_color_picker;
const defaultBreakpointColor = fd.deafult_breakpoint_color;
let data = [...payload.data.features];
if (fd.js_data_mutator) {
@@ -117,17 +127,62 @@ export const getLayer: GetLayerType<PolygonLayer> = function ({
data = jsFnMutator(data);
}
const colorSchemeType = fd.color_scheme_type;
const metricLabel = fd.metric ? fd.metric.label || fd.metric : null;
const accessor = (d: JsonObject) => d[metricLabel];
// base color for the polygons
const baseColorScaler =
fd.metric === null
? () => [fc.r, fc.g, fc.b, 255 * fc.a]
: getBreakPointColorScaler(fd, data, accessor);
let baseColorScaler: (d: JsonObject) => Color;
switch (colorSchemeType) {
case COLOR_SCHEME_TYPES.fixed_color: {
baseColorScaler = () => [fc.r, fc.g, fc.b, 255 * fc.a];
break;
}
case COLOR_SCHEME_TYPES.linear_palette: {
baseColorScaler =
fd.metric === null
? () => [fc.r, fc.g, fc.b, 255 * fc.a]
: getBreakPointColorScaler(fd, data, accessor);
break;
}
case COLOR_SCHEME_TYPES.color_breakpoints: {
const colorBreakpoints = fd.color_breakpoints;
baseColorScaler = data => {
const breakpointIndex = getColorForBreakpoints(
accessor,
data as number[],
colorBreakpoints,
);
const breakpointColor =
breakpointIndex !== undefined &&
colorBreakpoints[breakpointIndex - 1]?.color;
return breakpointColor
? [breakpointColor.r, breakpointColor.g, breakpointColor.b, 255]
: defaultBreakpointColor
? [
defaultBreakpointColor.r,
defaultBreakpointColor.g,
defaultBreakpointColor.b,
defaultBreakpointColor.a * 255,
]
: [
DEFAULT_DECKGL_COLOR.r,
DEFAULT_DECKGL_COLOR.g,
DEFAULT_DECKGL_COLOR.b,
DEFAULT_DECKGL_COLOR.a * 255,
];
};
break;
}
default:
baseColorScaler = () => [fc.r, fc.g, fc.b, 255 * fc.a];
break;
}
// when polygons are selected, reduce the opacity of non-selected polygons
const colorScaler = (d: JsonObject): [number, number, number, number] => {
const baseColor = (baseColorScaler?.(d) as [
const baseColor = (baseColorScaler(d) as [
number,
number,
number,
@@ -154,11 +209,11 @@ export const getLayer: GetLayerType<PolygonLayer> = function ({
stroked: fd.stroked,
getPolygon: getPointsFromPolygon,
getFillColor: colorScaler,
getLineColor: [sc.r, sc.g, sc.b, 255 * sc.a],
getLineColor: sc ? [sc.r, sc.g, sc.b, 255 * sc.a] : undefined,
getLineWidth: fd.line_width,
extruded: fd.extruded,
lineWidthUnits: fd.line_width_unit,
getElevation: (d: any) => getElevation(d, colorScaler),
getElevation: (d: JsonObject) => getElevation(d, colorScaler),
elevationScale: fd.multiplier,
fp64: true,
...commonLayerProps({
@@ -313,7 +368,10 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
: null;
const accessor = (d: JsonObject) => d[metricLabel];
const buckets = getBuckets(formData, payload.data.features, accessor);
const colorSchemeType = formData.color_scheme_type;
const buckets = colorSchemeType
? getColorBreakpointsBuckets(formData.color_breakpoints)
: getBuckets(formData, payload.data.features, accessor);
return (
<div style={{ position: 'relative' }}>

View File

@@ -22,7 +22,7 @@ import {
} from '@superset-ui/chart-controls';
import { t } from '@superset-ui/core';
import timeGrainSqlaAnimationOverrides from '../../utilities/controls';
import { formatSelectOptions } from '../../utilities/utils';
import { COLOR_SCHEME_TYPES, formatSelectOptions } from '../../utilities/utils';
import {
filterNulls,
autozoom,
@@ -44,6 +44,10 @@ import {
lineType,
reverseLongLat,
mapboxStyle,
deckGLCategoricalColorSchemeTypeSelect,
deckGLLinearColorSchemeSelect,
deckGLColorBreakpointsSelect,
breakpointsDefaultColor,
} from '../../utilities/Shared_DeckGL';
import { dndLineColumn } from '../../utilities/sharedDndControls';
@@ -96,7 +100,25 @@ const config: ControlPanelConfig = {
label: t('Polygon Settings'),
expanded: true,
controlSetRows: [
[fillColorPicker, strokeColorPicker],
[
{
...deckGLCategoricalColorSchemeTypeSelect,
config: {
...deckGLCategoricalColorSchemeTypeSelect.config,
choices: [
[COLOR_SCHEME_TYPES.fixed_color, t('Fixed color')],
[COLOR_SCHEME_TYPES.linear_palette, t('Linear palette')],
[COLOR_SCHEME_TYPES.color_breakpoints, t('Color breakpoints')],
],
default: COLOR_SCHEME_TYPES.linear_palette,
},
},
fillColorPicker,
strokeColorPicker,
deckGLLinearColorSchemeSelect,
breakpointsDefaultColor,
deckGLColorBreakpointsSelect,
],
[filled, stroked],
[extruded],
[multiplier],
@@ -116,7 +138,6 @@ const config: ControlPanelConfig = {
},
},
],
['linear_color_scheme'],
[
{
name: 'opacity',

View File

@@ -22,7 +22,6 @@ import timeGrainSqlaAnimationOverrides from '../../utilities/controls';
import {
filterNulls,
autozoom,
dimension,
jsColumns,
jsDataMutator,
jsTooltip,
@@ -34,7 +33,9 @@ import {
pointRadiusFixed,
multiplier,
mapboxStyle,
generateDeckGLColorSchemeControls,
} from '../../utilities/Shared_DeckGL';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
const config: ControlPanelConfig = {
onInit: controlState => ({
@@ -127,22 +128,11 @@ const config: ControlPanelConfig = {
{
label: t('Point Color'),
controlSetRows: [
['color_picker'],
[legendPosition],
[legendFormat],
[
{
name: dimension.name,
config: {
...dimension.config,
label: t('Categorical Color'),
description: t(
'Pick a dimension from which categorical colors are defined',
),
},
},
],
['color_scheme'],
...generateDeckGLColorSchemeControls({
defaultSchemeType: COLOR_SCHEME_TYPES.fixed_color,
}),
],
},
{

View File

@@ -20,9 +20,11 @@
*/
import { ScreenGridLayer } from '@deck.gl/aggregation-layers';
import { JsonObject, t } from '@superset-ui/core';
import { CategoricalColorNamespace, JsonObject, t } from '@superset-ui/core';
import { Color } from '@deck.gl/core';
import { COLOR_SCHEME_TYPES, ColorSchemeType } from '../../utilities/utils';
import sandboxedEval from '../../utils/sandbox';
import { commonLayerProps } from '../common';
import { commonLayerProps, getColorRange } from '../common';
import TooltipRow from '../../TooltipRow';
import { GetLayerType, createDeckGLComponent } from '../../factory';
@@ -41,7 +43,7 @@ function setTooltipContent(o: JsonObject) {
<TooltipRow
// eslint-disable-next-line prefer-template
label={t('Weight') + ': '}
value={`${o.object?.cellWeight}`}
value={`${o.object?.value}`}
/>
</div>
);
@@ -57,11 +59,9 @@ export const getLayer: GetLayerType<ScreenGridLayer> = function ({
emitCrossFilters,
}) {
const fd = formData;
const c = fd.color_picker;
let data = payload.data.features.map((d: JsonObject) => ({
...d,
color: [c.r, c.g, c.b, 255 * c.a],
}));
const appliedScheme = fd.color_scheme;
const colorScale = CategoricalColorNamespace.getScale(appliedScheme);
let data = payload.data.features;
if (fd.js_data_mutator) {
// Applying user defined data mutator if defined
@@ -69,16 +69,39 @@ export const getLayer: GetLayerType<ScreenGridLayer> = function ({
data = jsFnMutator(data);
}
const colorSchemeType = fd.color_scheme_type as ColorSchemeType & 'default';
const colorRange = getColorRange({
defaultBreakpointsColor: fd.deafult_breakpoint_color,
colorBreakpoints: fd.color_breakpoints,
fixedColor: fd.color_picker,
colorSchemeType,
colorScale,
});
const aggFunc = (d: JsonObject) => d.weight || 0;
const defaultScreenGridColorRange = [
[255, 255, 178],
[254, 217, 118],
[254, 178, 76],
[253, 141, 60],
[240, 59, 32],
[189, 0, 38],
] as Color[];
// Passing a layer creator function instead of a layer since the
// layer needs to be regenerated at each render
return new ScreenGridLayer({
id: `screengrid-layer-${fd.slice_id}` as const,
data,
cellSizePixels: fd.grid_size,
minColor: [c.r, c.g, c.b, 0],
maxColor: [c.r, c.g, c.b, 255 * c.a],
colorDomain:
colorSchemeType === COLOR_SCHEME_TYPES.color_breakpoints && colorRange
? [0, colorRange.length]
: undefined,
colorRange:
colorSchemeType === 'default' ? defaultScreenGridColorRange : colorRange,
outline: false,
getWeight: (d: any) => d.weight || 0,
...commonLayerProps({
formData: fd,
setDataMask,
@@ -88,6 +111,8 @@ export const getLayer: GetLayerType<ScreenGridLayer> = function ({
onContextMenu,
emitCrossFilters,
}),
getWeight: aggFunc,
colorScaleType: colorSchemeType === 'default' ? 'linear' : 'quantize',
});
};

View File

@@ -33,7 +33,11 @@ import {
viewport,
spatial,
mapboxStyle,
deckGLFixedColor,
deckGLCategoricalColorSchemeSelect,
deckGLCategoricalColorSchemeTypeSelect,
} from '../../utilities/Shared_DeckGL';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -55,7 +59,28 @@ const config: ControlPanelConfig = {
{
label: t('Grid'),
expanded: true,
controlSetRows: [[gridSize, 'color_picker']],
controlSetRows: [
[gridSize],
[
{
name: 'color_scheme_type',
config: {
...deckGLCategoricalColorSchemeTypeSelect.config,
choices: [
['default', 'Default'],
[COLOR_SCHEME_TYPES.fixed_color, t('Fixed color')],
[
COLOR_SCHEME_TYPES.categorical_palette,
t('Categorical palette'),
],
],
default: 'default',
},
},
],
[deckGLFixedColor],
[deckGLCategoricalColorSchemeSelect],
],
},
{
label: t('Advanced'),

View File

@@ -19,7 +19,15 @@
import { PickingInfo } from '@deck.gl/core';
import { JsonObject, QueryFormData } from '@superset-ui/core';
import { getAggFunc, commonLayerProps } from './common';
import {
getAggFunc,
commonLayerProps,
getColorForBreakpoints,
getColorRange,
} from './common';
import { ColorBreakpointType } from '../types';
import { COLOR_SCHEME_TYPES, ColorSchemeType } from '../utilities/utils';
import { DEFAULT_DECKGL_COLOR } from '../utilities/Shared_DeckGL';
const partialformData: Partial<QueryFormData> = {
viz_type: 'table',
@@ -159,3 +167,106 @@ describe('commonLayerProps', () => {
expect(mockOnSelect).toHaveBeenCalledWith('John Doe');
});
});
describe('getColorForBreakpoints', () => {
const colorBreakpoints: ColorBreakpointType[] = [
{ minValue: 0, maxValue: 10, color: { r: 255, g: 0, b: 0, a: 100 } },
{ minValue: 11, maxValue: 20, color: { r: 0, g: 255, b: 0, a: 100 } },
{ minValue: 21, maxValue: 30, color: { r: 0, g: 0, b: 255, a: 100 } },
];
it('returns correct breakpoint index for value in range', () => {
const aggFunc = (arr: number[]) => arr[0];
expect(getColorForBreakpoints(aggFunc, [5], colorBreakpoints)).toBe(1);
expect(getColorForBreakpoints(aggFunc, [15], colorBreakpoints)).toBe(2);
expect(getColorForBreakpoints(aggFunc, [25], colorBreakpoints)).toBe(3);
});
it('returns 0 if value is not in any breakpoint', () => {
const aggFunc = () => 100;
expect(getColorForBreakpoints(aggFunc, [100], colorBreakpoints)).toBe(0);
});
it('returns undefined if aggFunc returns undefined', () => {
const aggFunc = () => undefined;
expect(
getColorForBreakpoints(aggFunc, [5], colorBreakpoints),
).toBeUndefined();
});
it('returns undefined if aggFunc returns array', () => {
const aggFunc = () => [1, 2];
expect(
getColorForBreakpoints(aggFunc, [5], colorBreakpoints),
).toBeUndefined();
});
});
describe('getColorRange', () => {
const fdBase: any = {
color_picker: { r: 10, g: 20, b: 30, a: 0.5 },
color_breakpoints: [
{ minValue: 0, maxValue: 10, color: { r: 255, g: 0, b: 0, a: 1 } },
{ minValue: 11, maxValue: 20, color: { r: 0, g: 255, b: 0, a: 1 } },
],
};
it('returns color range for linear_palette', () => {
const colorScale = { range: () => ['#ff0000', '#00ff00'] } as any;
const result = getColorRange({
defaultBreakpointsColor: DEFAULT_DECKGL_COLOR,
colorSchemeType: COLOR_SCHEME_TYPES.linear_palette,
colorScale,
});
expect(result).toEqual([
[255, 0, 0, 255],
[0, 255, 0, 255],
]);
});
it('returns color range for categorical_palette', () => {
const colorScale = { range: () => ['#0000ff', '#00ffff'] } as any;
const result = getColorRange({
defaultBreakpointsColor: DEFAULT_DECKGL_COLOR,
colorSchemeType: COLOR_SCHEME_TYPES.categorical_palette,
colorScale,
});
expect(result).toEqual([
[0, 0, 255, 255],
[0, 255, 255, 255],
]);
});
it('returns color range for color_breakpoints', () => {
const result = getColorRange({
colorBreakpoints: fdBase.color_breakpoints,
defaultBreakpointsColor: DEFAULT_DECKGL_COLOR,
colorSchemeType: COLOR_SCHEME_TYPES.color_breakpoints,
});
expect(result).toEqual([
[
DEFAULT_DECKGL_COLOR.r,
DEFAULT_DECKGL_COLOR.g,
DEFAULT_DECKGL_COLOR.b,
DEFAULT_DECKGL_COLOR.a * 255,
],
[255, 0, 0, 255],
[0, 255, 0, 255],
]);
});
it('returns default color if color_picker is missing', () => {
const result = getColorRange({
defaultBreakpointsColor: DEFAULT_DECKGL_COLOR,
colorSchemeType: 'unknown_type' as ColorSchemeType,
});
expect(result).toEqual([
[
DEFAULT_DECKGL_COLOR.r,
DEFAULT_DECKGL_COLOR.g,
DEFAULT_DECKGL_COLOR.b,
DEFAULT_DECKGL_COLOR.a * 255,
],
]);
});
});

View File

@@ -29,6 +29,7 @@ import {
deviation as d3deviation,
} from 'd3-array';
import {
CategoricalColorScale,
FilterState,
HandlerFunction,
JsonObject,
@@ -36,10 +37,15 @@ import {
QueryFormData,
SetDataMaskHook,
} from '@superset-ui/core';
import { Layer, PickingInfo } from '@deck.gl/core';
import { Layer, PickingInfo, Color } from '@deck.gl/core';
import { ScaleLinear } from 'd3-scale';
import { ColorBreakpointType } from '../types';
import sandboxedEval from '../utils/sandbox';
import { TooltipProps } from '../components/Tooltip';
import { getCrossFilterDataMask } from '../utils/crossFiltersDataMask';
import { COLOR_SCHEME_TYPES, ColorSchemeType } from '../utilities/utils';
import { hexToRGB } from '../utils/colors';
import { DEFAULT_DECKGL_COLOR } from '../utilities/Shared_DeckGL';
export function commonLayerProps({
formData,
@@ -181,3 +187,87 @@ export function getAggFunc(
return (arr: number[]) => d3func(arr.map(x => accessor(x)));
}
export const getColorForBreakpoints = (
aggFunc: (arr: number[]) => number | number[] | undefined,
point: number[],
colorBreakpoints: ColorBreakpointType[],
) => {
const aggResult = aggFunc(point);
if (aggResult === undefined) return undefined;
if (Array.isArray(aggResult)) return undefined;
const breapointForPoint = colorBreakpoints.findIndex(
breakpoint =>
aggResult >= breakpoint.minValue && aggResult <= breakpoint.maxValue,
);
return breapointForPoint + 1;
};
export const getColorRange = ({
colorSchemeType,
fixedColor,
colorBreakpoints,
colorScale,
defaultBreakpointsColor,
}: {
colorSchemeType: ColorSchemeType;
defaultBreakpointsColor: { r: number; g: number; b: number; a: number };
fixedColor?: { r: number; g: number; b: number; a: number };
colorBreakpoints?: ColorBreakpointType[];
colorScale?: CategoricalColorScale | ScaleLinear<string, string>;
}) => {
let colorRange: Color[] | undefined;
switch (colorSchemeType) {
case COLOR_SCHEME_TYPES.linear_palette:
case COLOR_SCHEME_TYPES.categorical_palette: {
colorRange = colorScale?.range().map(color => hexToRGB(color)) as Color[];
break;
}
case COLOR_SCHEME_TYPES.color_breakpoints: {
const defaultColorArray: Color = defaultBreakpointsColor
? [
defaultBreakpointsColor.r,
defaultBreakpointsColor.g,
defaultBreakpointsColor.b,
defaultBreakpointsColor.a * 255,
]
: [
DEFAULT_DECKGL_COLOR.r,
DEFAULT_DECKGL_COLOR.g,
DEFAULT_DECKGL_COLOR.b,
DEFAULT_DECKGL_COLOR.a * 255,
];
colorRange = colorBreakpoints?.map(
(colorBreakpoint: ColorBreakpointType) =>
colorBreakpoint.color
? [
colorBreakpoint.color.r,
colorBreakpoint.color.g,
colorBreakpoint.color.b,
colorBreakpoint.color.a * 255,
]
: defaultColorArray,
);
colorRange?.unshift(defaultColorArray);
break;
}
default: {
const color = fixedColor || {
r: DEFAULT_DECKGL_COLOR.r,
g: DEFAULT_DECKGL_COLOR.g,
b: DEFAULT_DECKGL_COLOR.b,
a: DEFAULT_DECKGL_COLOR.a,
};
colorRange = [[color.r, color.g, color.b, color.a * 255]];
}
}
return colorRange;
};

View File

@@ -27,3 +27,9 @@ export interface ColorType {
b: number;
a: number;
}
export interface ColorBreakpointType {
color: ColorType;
minValue: number;
maxValue: number;
}

View File

@@ -25,33 +25,28 @@ import {
t,
validateNonEmpty,
validateMapboxStylesUrl,
getCategoricalSchemeRegistry,
getSequentialSchemeRegistry,
SequentialScheme,
} from '@superset-ui/core';
import { D3_FORMAT_OPTIONS, sharedControls } from '@superset-ui/chart-controls';
import {
ControlPanelState,
CustomControlItem,
D3_FORMAT_OPTIONS,
getColorControlsProps,
sharedControls,
} from '@superset-ui/chart-controls';
import { columnChoices, PRIMARY_COLOR } from './controls';
import {
COLOR_SCHEME_TYPES,
ColorSchemeType,
isColorSchemeTypeVisible,
} from './utils';
let deckglTiles;
const categoricalSchemeRegistry = getCategoricalSchemeRegistry();
const sequentialSchemeRegistry = getSequentialSchemeRegistry();
export const DEFAULT_DECKGL_TILES = [
['https://tile.openstreetmap.org/{z}/{x}/{y}.png', 'Streets (OSM)'],
['https://tile.osm.ch/osm-swiss-style/{z}/{x}/{y}.png', 'Topography (OSM)'],
['mapbox://styles/mapbox/streets-v9', 'Streets (Mapbox)'],
['mapbox://styles/mapbox/dark-v9', 'Dark (Mapbox)'],
['mapbox://styles/mapbox/light-v9', 'Light (Mapbox)'],
['mapbox://styles/mapbox/satellite-streets-v9', 'Satellite Streets (Mapbox)'],
['mapbox://styles/mapbox/satellite-v9', 'Satellite (Mapbox)'],
['mapbox://styles/mapbox/outdoors-v9', 'Outdoors (Mapbox)'],
];
const getDeckGLTiles = () => {
if (!deckglTiles) {
const appContainer = document.getElementById('app');
const { common } = JSON.parse(
appContainer?.getAttribute('data-bootstrap') || '{}',
);
deckglTiles = common?.deckgl_tiles ?? DEFAULT_DECKGL_TILES;
}
return deckglTiles;
};
export const DEFAULT_DECKGL_COLOR = { r: 158, g: 158, b: 158, a: 1 };
const DEFAULT_VIEWPORT = {
longitude: 6.85236157047845,
@@ -75,8 +70,8 @@ const jsFunctionInfo = (
);
function jsFunctionControl(
label,
description,
label: string,
description: string,
extraDescr = null,
height = 100,
defaultText = '',
@@ -127,7 +122,7 @@ export const autozoom = {
},
};
export const dimension = {
export const dimension: CustomControlItem = {
name: 'dimension',
config: {
...sharedControls.groupby,
@@ -220,7 +215,7 @@ export const lineColumn = {
label: t('Lines column'),
default: null,
description: t('The database columns that contains lines information'),
mapStateToProps: state => ({
mapStateToProps: (state: ControlPanelState) => ({
choices: columnChoices(state.datasource),
}),
validators: [validateNonEmpty],
@@ -239,7 +234,7 @@ export const lineWidth = {
},
};
export const fillColorPicker = {
export const fillColorPicker: CustomControlItem = {
name: 'fill_color_picker',
config: {
label: t('Fill Color'),
@@ -249,10 +244,12 @@ export const fillColorPicker = {
type: 'ColorPickerControl',
default: PRIMARY_COLOR,
renderTrigger: true,
visibility: ({ controls }) =>
isColorSchemeTypeVisible(controls, COLOR_SCHEME_TYPES.fixed_color),
},
};
export const strokeColorPicker = {
export const strokeColorPicker: CustomControlItem = {
name: 'stroke_color_picker',
config: {
label: t('Stroke Color'),
@@ -262,6 +259,8 @@ export const strokeColorPicker = {
type: 'ColorPickerControl',
default: PRIMARY_COLOR,
renderTrigger: true,
visibility: ({ controls }) =>
isColorSchemeTypeVisible(controls, COLOR_SCHEME_TYPES.fixed_color),
},
};
@@ -331,7 +330,7 @@ export const spatial = {
label: t('Longitude & Latitude'),
validators: [validateNonEmpty],
description: t('Point to your spatial columns'),
mapStateToProps: state => ({
mapStateToProps: (state: ControlPanelState) => ({
choices: columnChoices(state.datasource),
}),
},
@@ -344,7 +343,7 @@ export const pointRadiusFixed = {
label: t('Point Size'),
default: { type: 'fix', value: 1000 },
description: t('Fixed point radius'),
mapStateToProps: state => ({
mapStateToProps: (state: ControlPanelState) => ({
datasource: state.datasource,
}),
},
@@ -396,10 +395,17 @@ export const mapboxStyle = {
renderTrigger: true,
freeForm: true,
validators: [validateMapboxStylesUrl],
choices: getDeckGLTiles(),
default: getDeckGLTiles()[0][0],
choices: [
['mapbox://styles/mapbox/streets-v9', t('Streets')],
['mapbox://styles/mapbox/dark-v9', t('Dark')],
['mapbox://styles/mapbox/light-v9', t('Light')],
['mapbox://styles/mapbox/satellite-streets-v9', t('Satellite Streets')],
['mapbox://styles/mapbox/satellite-v9', t('Satellite')],
['mapbox://styles/mapbox/outdoors-v9', t('Outdoors')],
],
default: 'mapbox://styles/mapbox/light-v9',
description: t(
'Mapbox base layer map style (see Mapbox documentation: %s) or tile server URL.',
'Base layer map style. See Mapbox documentation: %s',
'https://docs.mapbox.com/help/glossary/style-url/',
),
},
@@ -412,8 +418,163 @@ export const geojsonColumn = {
label: t('GeoJson Column'),
validators: [validateNonEmpty],
description: t('Select the geojson column'),
mapStateToProps: state => ({
mapStateToProps: (state: ControlPanelState) => ({
choices: columnChoices(state.datasource),
}),
},
};
export const deckGLCategoricalColorSchemeTypeSelect: CustomControlItem = {
name: 'color_scheme_type',
config: {
type: 'SelectControl',
label: t('Color Scheme Type'),
description: t('Select the type of color scheme to use.'),
clearable: false,
renderTrigger: true,
validators: [],
choices: [
[COLOR_SCHEME_TYPES.fixed_color, t('Fixed color')],
[COLOR_SCHEME_TYPES.categorical_palette, t('Categorical palette')],
[COLOR_SCHEME_TYPES.color_breakpoints, t('Color breakpoints')],
],
default: COLOR_SCHEME_TYPES.categorical_palette,
},
};
export const deckGLFixedColor: CustomControlItem = {
name: 'color_picker',
config: {
type: 'ColorPickerControl',
label: t('Fixed Color'),
default: PRIMARY_COLOR,
renderTrigger: true,
description: t('Select the fixed color'),
visibility: ({ controls }) =>
isColorSchemeTypeVisible(controls, COLOR_SCHEME_TYPES.fixed_color),
},
};
export const deckGLCategoricalColor: CustomControlItem = {
name: dimension.name,
config: {
...dimension.config,
label: t('Categorical Color'),
description: t(
'Pick a dimension from which categorical colors are defined',
),
visibility: ({ controls }) =>
isColorSchemeTypeVisible(
controls,
COLOR_SCHEME_TYPES.categorical_palette,
),
},
};
export const deckGLCategoricalColorSchemeSelect: CustomControlItem = {
name: 'color_scheme',
config: {
type: 'ColorSchemeControl',
label: t('Color Scheme'),
default: categoricalSchemeRegistry.getDefaultKey(),
renderTrigger: true,
choices: () => categoricalSchemeRegistry.keys().map(s => [s, s]),
description: t('The color scheme for rendering chart'),
schemes: () => categoricalSchemeRegistry.getMap(),
visibility: ({ controls }) =>
isColorSchemeTypeVisible(
controls,
COLOR_SCHEME_TYPES.categorical_palette,
),
},
};
export const deckGLLinearColorSchemeSelect: CustomControlItem = {
name: 'linear_color_scheme',
config: {
type: 'ColorSchemeControl',
label: t('Linear Color Scheme'),
choices: () =>
(sequentialSchemeRegistry.values() as SequentialScheme[]).map(value => [
value.id,
value.label,
]),
default: sequentialSchemeRegistry.getDefaultKey(),
clearable: false,
description: t('Select a linear color scheme'),
renderTrigger: true,
schemes: () => sequentialSchemeRegistry.getMap(),
isLinear: true,
mapStateToProps: state => getColorControlsProps(state),
visibility: ({ controls }) =>
isColorSchemeTypeVisible(controls, COLOR_SCHEME_TYPES.linear_palette),
},
};
export const deckGLColorBreakpointsSelect: CustomControlItem = {
name: 'color_breakpoints',
config: {
label: t('Color breakpoints'),
type: 'ColorBreakpointsControl',
description: t('Define color breakpoints for the data'),
renderTrigger: true,
visibility: ({ controls }) =>
isColorSchemeTypeVisible(controls, COLOR_SCHEME_TYPES.color_breakpoints),
},
};
export const breakpointsDefaultColor: CustomControlItem = {
name: 'deafult_breakpoint_color',
config: {
label: t('Default color'),
type: 'ColorPickerControl',
description: t(
"The color used when a value doesn't match any defined breakpoints.",
),
default: DEFAULT_DECKGL_COLOR,
renderTrigger: true,
visibility: ({ controls }) =>
isColorSchemeTypeVisible(controls, COLOR_SCHEME_TYPES.color_breakpoints),
},
};
export const deckGLCategoricalColorSchemeControls = [
[deckGLCategoricalColorSchemeTypeSelect],
[deckGLFixedColor],
[deckGLCategoricalColor],
[deckGLCategoricalColorSchemeSelect],
[deckGLColorBreakpointsSelect],
];
export const generateDeckGLColorSchemeControls = ({
defaultSchemeType,
disableCategoricalColumn = false,
}: {
defaultSchemeType?: ColorSchemeType;
disableCategoricalColumn?: boolean;
}) => [
[
{
name: 'color_scheme_type',
config: {
type: 'SelectControl',
label: t('Color Scheme Type'),
description: t('Select the type of color scheme to use.'),
clearable: false,
renderTrigger: true,
validators: [],
choices: [
[COLOR_SCHEME_TYPES.fixed_color, t('Fixed color')],
[COLOR_SCHEME_TYPES.categorical_palette, t('Categorical palette')],
[COLOR_SCHEME_TYPES.color_breakpoints, t('Color breakpoints')],
],
default: defaultSchemeType || COLOR_SCHEME_TYPES.categorical_palette,
},
},
],
[deckGLFixedColor],
disableCategoricalColumn ? [] : [deckGLCategoricalColor],
[deckGLCategoricalColorSchemeSelect],
[breakpointsDefaultColor],
[deckGLColorBreakpointsSelect],
];

View File

@@ -16,8 +16,25 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ControlStateMapping } from '@superset-ui/chart-controls';
export const COLOR_SCHEME_TYPES = {
fixed_color: 'fixed_color',
categorical_palette: 'categorical_palette',
linear_palette: 'linear_palette',
color_breakpoints: 'color_breakpoints',
} as const;
export type ColorSchemeType =
(typeof COLOR_SCHEME_TYPES)[keyof typeof COLOR_SCHEME_TYPES];
/* eslint camelcase: 0 */
export function formatSelectOptions(options: (string | number)[]) {
return options.map(opt => [opt, opt.toString()]);
}
export const isColorSchemeTypeVisible = (
controls: ControlStateMapping,
colorSchemeType: ColorSchemeType,
) => controls.color_scheme_type.value === colorSchemeType;

View File

@@ -0,0 +1,46 @@
/**
* 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 { getColorBreakpointsBuckets } from './utils';
import { ColorBreakpointType } from './types';
describe('getColorBreakpointsBuckets', () => {
it('returns correct buckets for multiple breakpoints', () => {
const color_breakpoints: ColorBreakpointType[] = [
{ minValue: 0, maxValue: 10, color: { r: 255, g: 0, b: 0, a: 100 } },
{ minValue: 11, maxValue: 20, color: { r: 0, g: 255, b: 0, a: 100 } },
{ minValue: 21, maxValue: 30, color: { r: 0, g: 0, b: 255, a: 100 } },
];
const result = getColorBreakpointsBuckets(color_breakpoints);
expect(result).toEqual({
'0 - 10': { color: [255, 0, 0], enabled: true },
'11 - 20': { color: [0, 255, 0], enabled: true },
'21 - 30': { color: [0, 0, 255], enabled: true },
});
});
it('returns empty object if color_breakpoints is empty', () => {
const result = getColorBreakpointsBuckets([]);
expect(result).toEqual({});
});
it('returns empty object if color_breakpoints is missing', () => {
const result = getColorBreakpointsBuckets({} as any);
expect(result).toEqual({});
});
});

View File

@@ -25,16 +25,12 @@ import {
QueryFormData,
SequentialScheme,
} from '@superset-ui/core';
import { GeoBoundingBox, TileLayer } from '@deck.gl/geo-layers';
import { BitmapLayer, PathLayer } from '@deck.gl/layers';
import { Color } from '@deck.gl/core';
import { hexToRGB } from './utils/colors';
import { ColorBreakpointType } from './types';
const DEFAULT_NUM_BUCKETS = 10;
export const MAPBOX_LAYER_PREFIX = 'mapbox://';
export const TILE_LAYER_PREFIX = 'tile://';
export const OSM_LAYER_KEYWORDS = ['openstreetmap', 'osm'];
export type Buckets = {
break_points: string[];
num_buckets: string;
@@ -99,7 +95,7 @@ export function getBreakPointColorScaler(
}: BucketsWithColorScale,
features: JsonObject[],
accessor: (value: JsonObject) => number | undefined,
) {
): (data?: JsonObject) => Color {
const breakPoints =
formDataBreakPoints || formDataNumBuckets
? getBreakPoints(
@@ -119,7 +115,7 @@ export function getBreakPointColorScaler(
: getSequentialSchemeRegistry().get(linearColorScheme);
if (!colorScheme) {
return null;
return () => [0, 0, 0, 0];
}
let scaler: ScaleLinear<string, string> | ScaleThreshold<number, string>;
let maskPoint: (v: number | undefined) => boolean;
@@ -155,7 +151,7 @@ export function getBreakPointColorScaler(
maskPoint = () => false;
}
return (d: JsonObject): [number, number, number, number] => {
return (d: JsonObject): Color => {
const v = accessor(d);
if (!v) {
return [0, 0, 0, 0];
@@ -180,7 +176,7 @@ export function getBuckets(
const colorScaler = getBreakPointColorScaler(fd, features, accessor);
const buckets: Record<
string,
{ color: [number, number, number, number] | undefined; enabled: boolean }
{ color: Color | undefined; enabled: boolean }
> = {};
breakPoints.slice(1).forEach((_, i) => {
const range = `${breakPoints[i]} - ${breakPoints[i + 1]}`;
@@ -197,41 +193,25 @@ export function getBuckets(
return buckets;
}
export function buildTileLayer(url: string, id: string) {
interface TileLayerProps {
id: string;
data: string;
minZoom: number;
maxZoom: number;
tileSize: number;
renderSubLayers: (props: any) => (BitmapLayer | PathLayer)[];
export function getColorBreakpointsBuckets(
colorBreakpoints: ColorBreakpointType[],
) {
const breakpoints = colorBreakpoints || [];
const buckets: Record<string, { color: Color; enabled: boolean }> = {};
if (!breakpoints || !breakpoints.length) {
return buckets;
}
interface RenderSubLayerProps {
tile: {
bbox: GeoBoundingBox;
breakpoints.forEach((breakpoint: ColorBreakpointType) => {
const range = `${breakpoint.minValue} - ${breakpoint.maxValue}`;
buckets[range] = {
color: [breakpoint.color.r, breakpoint.color.g, breakpoint.color.b],
enabled: true,
};
data: any;
}
});
return new TileLayer({
data: url,
id,
minZoom: 0,
maxZoom: 19,
tileSize: 256,
renderSubLayers: (props: RenderSubLayerProps): BitmapLayer[] => {
const { west, north, east, south } = props.tile.bbox as GeoBoundingBox;
// Ajouter une BitmapLayer
const bitmapLayer = new BitmapLayer(props, {
data: undefined,
image: props.data,
bounds: [west, south, east, north],
});
return [bitmapLayer];
},
} as TileLayerProps);
return buckets;
}

View File

@@ -416,7 +416,7 @@ const processColumns = memoizeOne(function processColumns(
// percent metrics have a default format
formatter = getNumberFormatter(numberFormat || PERCENT_3_POINT);
} else if (isMetric || (isNumber && (numberFormat || currency))) {
formatter = currency
formatter = currency?.symbol
? new CurrencyFormatter({
d3Format: numberFormat,
currency,

View File

@@ -359,6 +359,22 @@ const config: ControlPanelConfig = {
[xAxisLabelRotation],
[xAxisLabelInterval],
...richTooltipSection,
[
{
name: 'show_query_identifiers',
config: {
type: 'CheckboxControl',
label: t('Show query identifiers'),
description: t(
'Adds Query A and Query B identifiers to help differentiate series',
),
default: false,
renderTrigger: true,
visibility: ({ controls }) =>
Boolean(controls?.rich_tooltip?.value),
},
},
],
// eslint-disable-next-line react/jsx-key
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
[

View File

@@ -212,6 +212,7 @@ export default function transformProps(
sortSeriesAscendingB,
timeGrainSqla,
percentageThreshold,
show_query_identifiers = false,
metrics = [],
metricsB = [],
}: EchartsMixedTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
@@ -395,10 +396,19 @@ export default function transformProps(
const seriesName = inverted[entryName] || entryName;
const colorScaleKey = getOriginalSeries(seriesName, array);
let displayName = `${entryName} (Query A)`;
let displayName: string;
if (groupby.length > 0) {
displayName = `${MetricDisplayNameA} (Query A), ${entryName}`;
// When we have groupby, format as "metric, dimension"
const metricPart = show_query_identifiers
? `${MetricDisplayNameA} (Query A)`
: MetricDisplayNameA;
displayName = `${metricPart}, ${entryName}`;
} else {
// When no groupby, format as just the entry name with optional query identifier
displayName = show_query_identifiers
? `${entryName} (Query A)`
: entryName;
}
const seriesFormatter = getFormatter(
@@ -453,10 +463,19 @@ export default function transformProps(
const seriesName = `${seriesEntry} (1)`;
const colorScaleKey = getOriginalSeries(seriesEntry, array);
let displayName = `${entryName} (Query B)`;
let displayName: string;
if (groupbyB.length > 0) {
displayName = `${MetricDisplayNameB} (Query B), ${entryName}`;
// When we have groupby, format as "metric, dimension"
const metricPart = show_query_identifiers
? `${MetricDisplayNameB} (Query B)`
: MetricDisplayNameB;
displayName = `${metricPart}, ${entryName}`;
} else {
// When no groupby, format as just the entry name with optional query identifier
displayName = show_query_identifiers
? `${entryName} (Query B)`
: entryName;
}
const seriesFormatter = getFormatter(

View File

@@ -60,6 +60,7 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & {
tooltipTimeFormat?: string;
zoomable: boolean;
richTooltip: boolean;
show_query_identifiers?: boolean;
xAxisLabelRotation: number;
xAxisLabelInterval?: number | string;
colorScheme?: string;
@@ -133,6 +134,7 @@ export const DEFAULT_FORM_DATA: EchartsMixedTimeseriesFormData = {
groupbyB: [],
zoomable: TIMESERIES_DEFAULTS.zoomable,
richTooltip: TIMESERIES_DEFAULTS.richTooltip,
show_query_identifiers: false,
xAxisLabelRotation: TIMESERIES_DEFAULTS.xAxisLabelRotation,
xAxisLabelInterval: TIMESERIES_DEFAULTS.xAxisLabelInterval,
...DEFAULT_TITLE_FORM_DATA,

View File

@@ -209,12 +209,18 @@ export default function EchartsTimeseries({
}),
);
groupBy.forEach((dimension, i) => {
const val = labelMap[seriesName][i];
const dimensionValues = labelMap[seriesName] ?? [];
// Skip the metric values at the beginning and get the actual dimension value
// If we have multiple metrics, they come first, then the dimension values
const metricsCount = dimensionValues.length - groupBy.length;
const val = dimensionValues[metricsCount + i];
drillByFilters.push({
col: dimension,
op: '==',
val,
formattedVal: formatSeriesName(values[i], {
formattedVal: formatSeriesName(val, {
timeFormatter: getTimeFormatter(formData.dateFormat),
numberFormatter: getNumberFormatter(formData.numberFormat),
coltype: coltypeMapping?.[getColumnLabel(dimension)],

View File

@@ -28,7 +28,7 @@ import {
EchartsMixedTimeseriesProps,
} from '../../src/MixedTimeseries/types';
const formData: EchartsMixedTimeseriesFormData = {
const baseFormData: EchartsMixedTimeseriesFormData = {
annotationLayers: [],
area: false,
areaB: false,
@@ -81,6 +81,7 @@ const formData: EchartsMixedTimeseriesFormData = {
forecastPeriods: [],
forecastInterval: 0,
forecastSeasonalityDaily: 0,
show_query_identifiers: false,
};
const queriesData = [
@@ -108,57 +109,118 @@ const queriesData = [
},
];
const chartPropsConfig = {
formData,
width: 800,
height: 600,
queriesData,
theme: supersetTheme,
};
describe('MixedTimeseries transformProps', () => {
it('should transform chart props for viz with query identifiers disabled', () => {
const formData = { ...baseFormData, show_query_identifiers: false };
const chartPropsConfig = {
formData,
width: 800,
height: 600,
queriesData,
theme: supersetTheme,
};
const chartProps = new ChartProps({ ...chartPropsConfig, formData });
const transformed = transformProps(
chartProps as EchartsMixedTimeseriesProps,
);
it('should transform chart props for viz', () => {
const chartProps = new ChartProps(chartPropsConfig);
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
expect(transformed).toEqual(
expect.objectContaining({
echartOptions: expect.objectContaining({
series: expect.arrayContaining([
expect.objectContaining({
data: [
[599616000000, 1],
[599916000000, 3],
],
id: 'sum__num (Query A), boy',
stack: 'obs\na',
}),
expect.objectContaining({
data: [
[599616000000, 2],
[599916000000, 4],
],
id: 'sum__num (Query A), girl',
stack: 'obs\na',
}),
// Query B — Bar series
expect.objectContaining({
data: [
[599616000000, 1],
[599916000000, 3],
],
id: 'sum__num (Query B), boy',
stack: 'obs\nb',
}),
expect.objectContaining({
data: [
[599616000000, 2],
[599916000000, 4],
],
id: 'sum__num (Query B), girl',
stack: 'obs\nb',
}),
]),
expect(transformed).toEqual(
expect.objectContaining({
echartOptions: expect.objectContaining({
series: expect.arrayContaining([
expect.objectContaining({
data: [
[599616000000, 1],
[599916000000, 3],
],
id: 'sum__num, boy',
stack: 'obs\na',
}),
expect.objectContaining({
data: [
[599616000000, 2],
[599916000000, 4],
],
id: 'sum__num, girl',
stack: 'obs\na',
}),
// Query B — Bar series
expect.objectContaining({
data: [
[599616000000, 1],
[599916000000, 3],
],
id: 'sum__num, boy',
stack: 'obs\nb',
}),
expect.objectContaining({
data: [
[599616000000, 2],
[599916000000, 4],
],
id: 'sum__num, girl',
stack: 'obs\nb',
}),
]),
}),
}),
}),
);
);
});
it('should transform chart props for viz with query identifiers enabled', () => {
const formData = { ...baseFormData, show_query_identifiers: true };
const chartPropsConfig = {
formData,
width: 800,
height: 600,
queriesData,
theme: supersetTheme,
};
const chartProps = new ChartProps({ ...chartPropsConfig, formData });
const transformed = transformProps(
chartProps as EchartsMixedTimeseriesProps,
);
expect(transformed).toEqual(
expect.objectContaining({
echartOptions: expect.objectContaining({
series: expect.arrayContaining([
expect.objectContaining({
data: [
[599616000000, 1],
[599916000000, 3],
],
id: 'sum__num (Query A), boy',
stack: 'obs\na',
}),
expect.objectContaining({
data: [
[599616000000, 2],
[599916000000, 4],
],
id: 'sum__num (Query A), girl',
stack: 'obs\na',
}),
// Query B — Bar series
expect.objectContaining({
data: [
[599616000000, 1],
[599916000000, 3],
],
id: 'sum__num (Query B), boy',
stack: 'obs\nb',
}),
expect.objectContaining({
data: [
[599616000000, 2],
[599916000000, 4],
],
id: 'sum__num (Query B), girl',
stack: 'obs\nb',
}),
]),
}),
}),
);
});
});

View File

@@ -276,7 +276,7 @@ const processColumns = memoizeOne(function processColumns(
// percent metrics have a default format
formatter = getNumberFormatter(numberFormat || PERCENT_3_POINT);
} else if (isMetric || (isNumber && (numberFormat || currency))) {
formatter = currency
formatter = currency?.symbol
? new CurrencyFormatter({
d3Format: numberFormat,
currency,

View File

@@ -22,6 +22,8 @@
set -e
export NODE_NO_WARNINGS=1
for file in $( find ../superset/translations/** -name '*.po' );
do
extension=${file##*.}
@@ -29,7 +31,7 @@ do
if [ $extension == "po" ]
then
echo "po2json --domain superset --format jed1.x $file $filename.json"
po2json --domain superset --format jed1.x $file $filename.json
po2json --domain superset --format jed1.x --fuzzy $file $filename.json
prettier --write $filename.json
fi
done

View File

@@ -926,7 +926,7 @@ const SqlEditor: FC<Props> = ({
css={css`
margin-bottom: ${theme.sizeUnit * 2}px;
padding-top: ${theme.sizeUnit * 4}px;
.antd5-alert-action {
.ant-alert-action {
align-self: center;
}
`}

View File

@@ -353,7 +353,7 @@ test('Sends the correct db when changing the database', async () => {
await waitFor(() =>
expect(props.onDbChange).toHaveBeenCalledWith(
expect.objectContaining({
id: `mysql-test-mysql-2`,
value: 2,
database_name: 'test-mysql',
backend: 'mysql',
}),

View File

@@ -319,11 +319,14 @@ export function DatabaseSelector({
value: { label: string; value: number },
database: DatabaseValue,
) {
setCurrentDb(database);
// the database id is actually stored in the value property; the ID is used
// for the DOM, so it can't be an integer
const databaseWithId = { ...database, id: database.value };
setCurrentDb(databaseWithId);
setCurrentCatalog(undefined);
setCurrentSchema(undefined);
if (onDbChange) {
onDbChange(database);
onDbChange(databaseWithId);
}
if (onCatalogChange) {
onCatalogChange(undefined);

View File

@@ -717,7 +717,9 @@ class DatasourceEditor extends PureComponent {
}
onDatasourceTypeChange(datasourceType) {
this.setState({ datasourceType });
// Call onChange after setting datasourceType to ensure
// SQL is cleared when switching to a physical dataset
this.setState({ datasourceType }, this.onChange);
}
setColumns(obj) {

View File

@@ -22,6 +22,7 @@ import {
screen,
waitFor,
userEvent,
cleanup,
} from 'spec/helpers/testing-library';
import mockDatasource from 'spec/fixtures/mockDatasource';
import { isFeatureEnabled } from '@superset-ui/core';
@@ -244,4 +245,36 @@ describe('DatasourceEditor Source Tab', () => {
expect(physicalRadioBtn).toBeDisabled();
expect(virtualRadioBtn).toBeDisabled();
});
it('calls onChange with empty SQL when switching to physical dataset', async () => {
// Clean previous render
cleanup();
props.onChange.mockClear();
await asyncRender({
...props,
datasource: {
...props.datasource,
table_name: 'Vehicle Sales +',
datasourceType: 'virtual',
sql: 'SELECT * FROM users',
},
});
// Enable edit mode
const getLockBtn = screen.getByRole('img', { name: /lock/i });
await userEvent.click(getLockBtn);
// Switch to physical dataset
const physicalRadioBtn = screen.getByRole('radio', {
name: /physical \(table or view\)/i,
});
await userEvent.click(physicalRadioBtn);
// Assert that the latest onChange call has empty SQL
expect(props.onChange).toHaveBeenCalled();
const updatedDatasource = props.onChange.mock.calls[0];
expect(updatedDatasource[0].sql).toBe('');
});
});

View File

@@ -70,6 +70,7 @@ export const ErrorAlert: React.FC<ErrorAlertProps> = ({
const preStyle = {
whiteSpace: 'pre-wrap',
fontFamily: theme.fontFamilyCode,
margin: `${theme.sizeUnit}px 0`,
};
const renderDescription = () => (
<div>

View File

@@ -92,7 +92,7 @@ export function MarshmallowErrorMessage({
children: (
<JSONTree
data={extra.messages}
shouldExpandNode={() => true}
shouldExpandNodeInitially={() => true}
hideRoot
theme={jsonTreeTheme}
/>

View File

@@ -47,6 +47,15 @@ export const TagsList = ({
onDelete,
maxTags,
}: TagsListProps) => {
const sortedTags = useMemo(
() =>
tags.toSorted((a, b) => {
const nameA = a.name ?? '';
const nameB = b.name ?? '';
return nameA.localeCompare(nameB);
}),
[tags],
);
const [tempMaxTags, setTempMaxTags] = useState<number | undefined>(maxTags);
const handleDelete = (index: number) => {
@@ -58,21 +67,23 @@ export const TagsList = ({
const collapse = () => setTempMaxTags(maxTags);
const tagsIsLong: boolean | null = useMemo(
() => (tempMaxTags ? tags.length > tempMaxTags : null),
[tags.length, tempMaxTags],
() => (tempMaxTags ? sortedTags.length > tempMaxTags : null),
[sortedTags.length, tempMaxTags],
);
const extraTags: number | null = useMemo(
() =>
typeof tempMaxTags === 'number' ? tags.length - tempMaxTags + 1 : null,
[tagsIsLong, tags.length, tempMaxTags],
typeof tempMaxTags === 'number'
? sortedTags.length - tempMaxTags + 1
: null,
[tagsIsLong, sortedTags.length, tempMaxTags],
);
return (
<TagsDiv className="tag-list">
{tagsIsLong && typeof tempMaxTags === 'number' ? (
<>
{tags.slice(0, tempMaxTags - 1).map((tag: TagType, index) => (
{sortedTags.slice(0, tempMaxTags - 1).map((tag: TagType, index) => (
<Tag
id={tag.id}
key={tag.id}
@@ -82,17 +93,17 @@ export const TagsList = ({
editable={editable}
/>
))}
{tags.length > tempMaxTags ? (
{sortedTags.length > tempMaxTags ? (
<Tag
name={`+${extraTags}...`}
onClick={expand}
toolTipTitle={tags.map(t => t.name).join(', ')}
toolTipTitle={sortedTags.map(t => t.name).join(', ')}
/>
) : null}
</>
) : (
<>
{tags.map((tag: TagType, index) => (
{sortedTags.map((tag: TagType, index) => (
<Tag
id={tag.id}
key={tag.id}
@@ -103,7 +114,7 @@ export const TagsList = ({
/>
))}
{maxTags ? (
tags.length > maxTags ? (
sortedTags.length > maxTags ? (
<Tag name="..." onClick={collapse} />
) : null
) : null}

View File

@@ -17,7 +17,6 @@
* under the License.
*/
import { DEFAULT_D3_FORMAT, DEFAULT_D3_TIME_FORMAT } from '@superset-ui/core';
import { BootstrapData, CommonBootstrapData } from './types/bootstrapTypes';
export const DATETIME_WITH_TIME_ZONE = 'YYYY-MM-DD HH:mm:ssZ';
@@ -141,7 +140,11 @@ export const DEFAULT_COMMON_BOOTSTRAP_DATA: CommonBootstrapData = {
},
extra_categorical_color_schemes: [],
extra_sequential_color_schemes: [],
theme: {},
theme: {
default: {},
dark: {},
settings: {},
},
menu_data: {
menu: [],
brand: {

View File

@@ -294,6 +294,7 @@ export function saveDashboardRequest(data, id, saveType) {
owners,
roles,
slug,
tags,
} = data;
const hasId = item => item.id !== undefined;
@@ -315,6 +316,9 @@ export function saveDashboardRequest(data, id, saveType) {
? undefined
: ensureIsArray(roles).map(r => (hasId(r) ? r.id : r)),
slug: slug || null,
tags: !isFeatureEnabled(FeatureFlag.TaggingSystem)
? undefined
: ensureIsArray(tags || []).map(r => (hasId(r) ? r.id : r)),
metadata: {
...data.metadata,
color_namespace: getColorNamespace(data.metadata?.color_namespace),
@@ -445,6 +449,7 @@ export function saveDashboardRequest(data, id, saveType) {
slug: cleanedData.slug,
owners: cleanedData.owners,
roles: cleanedData.roles,
tags: cleanedData.tags || [],
json_metadata: safeStringify({
...(cleanedData?.metadata || {}),
default_filters: safeStringify(serializedFilters),

View File

@@ -23,6 +23,11 @@ import {
within,
screen,
} from 'spec/helpers/testing-library';
import { FeatureFlag } from '@superset-ui/core';
import {
OPEN_FILTER_BAR_WIDTH,
CLOSED_FILTER_BAR_WIDTH,
} from 'src/dashboard/constants';
import DashboardBuilder from 'src/dashboard/components/DashboardBuilder/DashboardBuilder';
import useStoredSidebarWidth from 'src/components/ResizableSidebar/useStoredSidebarWidth';
import {
@@ -268,6 +273,46 @@ describe('DashboardBuilder', () => {
expect(filterbar).toHaveStyleRule('width', `${expectedValue}px`);
});
it('filter panel state when featureflag is true', () => {
window.featureFlags = {
[FeatureFlag.FilterBarClosedByDefault]: true,
};
const setter = jest.fn();
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
CLOSED_FILTER_BAR_WIDTH,
setter,
]);
const { getByTestId } = setup({
dashboardInfo: {
...mockState.dashboardInfo,
dash_edit_perm: true,
},
});
const filterbar = getByTestId('dashboard-filters-panel');
expect(filterbar).toHaveStyleRule('width', `${CLOSED_FILTER_BAR_WIDTH}px`);
});
it('filter panel state when featureflag is false', () => {
window.featureFlags = {
[FeatureFlag.FilterBarClosedByDefault]: false,
};
const setter = jest.fn();
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
OPEN_FILTER_BAR_WIDTH,
setter,
]);
const { getByTestId } = setup({
dashboardInfo: {
...mockState.dashboardInfo,
dash_edit_perm: true,
},
});
const filterbar = getByTestId('dashboard-filters-panel');
expect(filterbar).toHaveStyleRule('width', `${OPEN_FILTER_BAR_WIDTH}px`);
});
it('should not render the filter bar when nativeFiltersEnabled is false', () => {
jest.spyOn(useNativeFiltersModule, 'useNativeFilters').mockReturnValue({
showDashboard: true,

View File

@@ -21,6 +21,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
import { RootState } from 'src/dashboard/types';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import {
useFilters,
useNativeFiltersDataMask,
@@ -71,6 +72,8 @@ export const useNativeFilters = () => {
useEffect(() => {
if (
(isFeatureEnabled(FeatureFlag.FilterBarClosedByDefault) &&
expandFilters === null) ||
expandFilters === false ||
(filterValues.length === 0 && nativeFiltersEnabled)
) {

View File

@@ -415,6 +415,7 @@ const Header = () => {
owners: dashboardInfo.owners,
roles: dashboardInfo.roles,
slug,
tags: dashboardInfo.tags,
metadata: {
...dashboardInfo?.metadata,
color_namespace: currentColorNamespace,
@@ -525,6 +526,7 @@ const Header = () => {
certification_details: updates.certificationDetails,
owners: updates.owners,
roles: updates.roles,
tags: updates.tags,
});
boundActionCreators.setUnsavedChanges(true);
boundActionCreators.dashboardTitleChanged(updates.title);

View File

@@ -383,7 +383,7 @@ const PropertiesModal = ({
currentJsonMetadata = jsonStringify(metadata);
const moreOnSubmitProps: { roles?: Roles } = {};
const moreOnSubmitProps: { roles?: Roles; tags?: TagType[] } = {};
const morePutProps: {
roles?: number[];
tags?: (string | number | undefined)[];
@@ -393,6 +393,7 @@ const PropertiesModal = ({
morePutProps.roles = (roles || []).map(r => r.id);
}
if (isFeatureEnabled(FeatureFlag.TaggingSystem)) {
moreOnSubmitProps.tags = tags;
morePutProps.tags = tags.map(tag => tag.id);
}
const onSubmitProps = {
@@ -600,6 +601,10 @@ const PropertiesModal = ({
setTags(parsedTags);
};
const handleClearTags = () => {
setTags([]);
};
return (
<Modal
show={show}
@@ -725,6 +730,7 @@ const PropertiesModal = ({
value={tagsAsSelectValues}
options={loadTags}
onChange={handleChangeTags}
onClear={handleClearTags}
allowClear
/>
</FormItem>

View File

@@ -173,7 +173,7 @@ test('Should have modal header', async () => {
await waitFor(() => {
expect(screen.getByText('Edit Chart Properties')).toBeVisible();
expect(screen.getByText('×')).toBeVisible();
expect(screen.getByTestId('close-modal-btn')).toBeVisible();
expect(screen.getByRole('button', { name: 'Close' })).toBeVisible();
});
});

View File

@@ -0,0 +1,125 @@
/**
* 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 {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import ColorBreakpointOption from './ColorBreakpointOption';
import { ColorBreakpointType, ColorBreakpointOptionProps } from './types';
const mockBreakpoint: ColorBreakpointType = {
id: 0,
color: { r: 255, g: 0, b: 0, a: 1 },
minValue: 0,
maxValue: 100,
};
const mockBreakpoints: ColorBreakpointType[] = [mockBreakpoint];
const createProps = (): ColorBreakpointOptionProps => ({
breakpoint: mockBreakpoint,
colorBreakpoints: mockBreakpoints,
index: 0,
saveColorBreakpoint: jest.fn(),
onClose: jest.fn(),
onShift: jest.fn(),
});
const renderComponent = (props: Partial<ColorBreakpointOptionProps> = {}) =>
render(<ColorBreakpointOption {...createProps()} {...props} />, {
useDnd: true,
});
describe('ColorBreakpointOption', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('should render', async () => {
const { container } = renderComponent();
await waitFor(() => expect(container).toBeInTheDocument());
});
test('should render the breakpoint range text', async () => {
renderComponent();
expect(await screen.findByText('0 - 100')).toBeInTheDocument();
});
test('should render the remove button', async () => {
renderComponent();
const removeBtn = await screen.findByTestId('remove-control-button');
expect(removeBtn).toBeInTheDocument();
});
test('should render the color preview', async () => {
renderComponent();
const colorPreview = await screen.findByTestId('color-preview');
expect(colorPreview).toBeInTheDocument();
});
test('should call onClose when remove button is clicked', async () => {
const onClose = jest.fn();
renderComponent({ onClose });
const removeBtn = await screen.findByTestId('remove-control-button');
userEvent.click(removeBtn);
expect(onClose).toHaveBeenCalledWith(0);
});
test('should open popover when clicked', async () => {
renderComponent();
const breakpointOption = await screen.findByTestId(
'color-breakpoint-trigger',
);
userEvent.click(breakpointOption);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
test('should render different color values correctly', async () => {
const blueBreakpoint: ColorBreakpointType = {
id: 1,
color: { r: 0, g: 0, b: 255, a: 1 },
minValue: 50,
maxValue: 150,
};
renderComponent({ breakpoint: blueBreakpoint });
expect(await screen.findByText('50 - 150')).toBeInTheDocument();
const colorPreview = await screen.findByTestId('color-preview');
expect(colorPreview).toBeInTheDocument();
});
test('should handle decimal values', async () => {
const decimalBreakpoint: ColorBreakpointType = {
id: 2,
color: { r: 128, g: 128, b: 128, a: 1 },
minValue: 0.5,
maxValue: 99.9,
};
renderComponent({ breakpoint: decimalBreakpoint });
expect(await screen.findByText('0.5 - 99.9')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,66 @@
/**
* 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 { styled } from '@superset-ui/core';
import { ColorBreakpointOptionProps } from './types';
import ColorBreakpointPopoverTrigger from './ColorBreakpointPopoverTrigger';
import { DragContainer } from '../OptionControls';
import Option from '../DndColumnSelectControl/Option';
const BreakpointColorPreview = styled.div`
width: ${({ theme }) => theme.sizeUnit * 3}px;
height: ${({ theme }) => theme.sizeUnit * 3}px;
border-radius: ${({ theme }) => theme.sizeUnit / 2}px;
background: ${(props: { color: string }) => props.color};
margin-right: ${({ theme }) => theme.sizeUnit}px;
`;
const ColorBreakpointOption = ({
breakpoint,
colorBreakpoints,
index,
saveColorBreakpoint,
onClose,
}: ColorBreakpointOptionProps) => {
const { color, minValue, maxValue } = breakpoint;
const formattedColor = color
? `rgba(${color.r}, ${color.g}, ${color.b}, 1)`
: '';
return (
<ColorBreakpointPopoverTrigger
saveColorBreakpoint={saveColorBreakpoint}
value={breakpoint}
colorBreakpoints={colorBreakpoints}
>
<DragContainer data-test="color-breakpoint-trigger">
<Option index={index} clickClose={onClose} canDelete withCaret>
<BreakpointColorPreview
color={formattedColor}
data-test="color-preview"
/>
{`${minValue} - ${maxValue}`}
</Option>
</DragContainer>
</ColorBreakpointPopoverTrigger>
);
};
export default ColorBreakpointOption;

View File

@@ -0,0 +1,312 @@
/**
* 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 { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import ColorBreakpointPopoverControl from './ColorBreakpointPopoverControl';
import {
ColorBreakpointType,
ColorBreakpointsPopoverControlProps,
} from './types';
const mockBreakpoint: ColorBreakpointType = {
id: 0,
color: { r: 255, g: 0, b: 0, a: 100 },
minValue: 0,
maxValue: 100,
};
const mockBreakpoints: ColorBreakpointType[] = [mockBreakpoint];
// Couldn't test the color select - providing color instead of interacting with the color select component
const mockEmptyBreakpoint: ColorBreakpointType = {
id: 0,
color: { r: 0, g: 0, b: 0, a: 0 },
};
const createProps = (): ColorBreakpointsPopoverControlProps => ({
value: mockBreakpoint,
onSave: jest.fn(),
onClose: jest.fn(),
colorBreakpoints: mockBreakpoints,
});
const renderComponent = (
props: Partial<ColorBreakpointsPopoverControlProps> = {},
) => render(<ColorBreakpointPopoverControl {...createProps()} {...props} />);
describe('ColorBreakpointPopoverControl', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('should render with default props', () => {
renderComponent();
expect(screen.getByText('Color for breakpoint')).toBeInTheDocument();
expect(screen.getByText('Min value')).toBeInTheDocument();
expect(screen.getByText('Max value')).toBeInTheDocument();
});
test('should render close and save buttons', () => {
renderComponent();
expect(screen.getByTestId('close-button')).toBeInTheDocument();
expect(screen.getByTestId('save-button')).toBeInTheDocument();
});
test('should render with existing breakpoint values', () => {
renderComponent();
expect(screen.getByDisplayValue('0')).toBeInTheDocument();
expect(screen.getByDisplayValue('100')).toBeInTheDocument();
});
test('should call onClose when close button is clicked', async () => {
const onClose = jest.fn();
renderComponent({ onClose });
const closeButton = screen.getByTestId('close-button');
userEvent.click(closeButton);
expect(onClose).toHaveBeenCalled();
});
test('should disable save button when form is incomplete', () => {
renderComponent({ value: undefined });
const saveButton = screen.getByTestId('save-button');
expect(saveButton).toBeDisabled();
});
test('should enable save button when form is complete', async () => {
renderComponent({ value: mockEmptyBreakpoint });
const minInput = screen.getByTestId('min-value-input');
const maxInput = screen.getByTestId('max-value-input');
userEvent.type(minInput, '10');
userEvent.type(maxInput, '90');
await waitFor(() => {
const saveButton = screen.getByTestId('save-button');
expect(saveButton).toBeEnabled();
});
});
test('should call onSave with correct values when save button is clicked', async () => {
const onSave = jest.fn();
const onClose = jest.fn();
renderComponent({
onSave,
onClose,
value: mockEmptyBreakpoint,
});
const minInput = screen.getByTestId('min-value-input');
const maxInput = screen.getByTestId('max-value-input');
userEvent.type(minInput, '10');
userEvent.type(maxInput, '90');
await waitFor(() => {
const saveButton = screen.getByTestId('save-button');
expect(saveButton).toBeEnabled();
});
const saveButton = screen.getByTestId('save-button');
userEvent.click(saveButton);
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
minValue: 10,
maxValue: 90,
color: expect.objectContaining({
r: expect.any(Number),
g: expect.any(Number),
b: expect.any(Number),
a: expect.any(Number),
}),
}),
);
expect(onClose).toHaveBeenCalled();
});
test('should disable save button when min value >= max value', async () => {
renderComponent({ value: undefined });
const minInput = screen.getByTestId('min-value-input');
const maxInput = screen.getByTestId('max-value-input');
userEvent.type(minInput, '100');
userEvent.type(maxInput, '50');
const saveButton = screen.getByTestId('save-button');
expect(saveButton).toBeDisabled();
});
test('should disable save button when breakpoint overlaps with existing ones', async () => {
const existingBreakpoints: ColorBreakpointType[] = [
{
id: 0,
color: { r: 255, g: 0, b: 0, a: 100 },
minValue: 0,
maxValue: 50,
},
{
id: 1,
color: { r: 0, g: 255, b: 0, a: 100 },
minValue: 100,
maxValue: 200,
},
];
renderComponent({
colorBreakpoints: existingBreakpoints,
value: undefined,
});
const minInput = screen.getByTestId('min-value-input');
const maxInput = screen.getByTestId('max-value-input');
userEvent.type(minInput, '25');
userEvent.type(maxInput, '75');
const saveButton = screen.getByTestId('save-button');
expect(saveButton).toBeDisabled();
});
test('should handle non-numeric input validation', async () => {
renderComponent({ value: undefined });
const minInput = screen.getByTestId('min-value-input');
const maxInput = screen.getByTestId('max-value-input');
userEvent.type(minInput, 'abc');
userEvent.type(maxInput, '100');
await waitFor(() => {
const saveButton = screen.getByTestId('save-button');
expect(saveButton).toBeDisabled();
});
});
test('should update min value when input changes', async () => {
renderComponent();
const minInput = screen.getByDisplayValue('0');
userEvent.clear(minInput);
userEvent.type(minInput, '20');
expect(screen.getByDisplayValue('20')).toBeInTheDocument();
});
test('should update max value when input changes', async () => {
renderComponent();
const maxInput = screen.getByDisplayValue('100');
userEvent.clear(maxInput);
userEvent.type(maxInput, '200');
expect(screen.getByDisplayValue('200')).toBeInTheDocument();
});
test('should handle zero values correctly', async () => {
renderComponent({
value: mockEmptyBreakpoint,
});
const minInput = screen.getByTestId('min-value-input');
const maxInput = screen.getByTestId('max-value-input');
userEvent.type(minInput, '0');
userEvent.type(maxInput, '10');
await waitFor(() => {
const saveButton = screen.getByTestId('save-button');
expect(saveButton).toBeEnabled();
});
});
test('should handle negative values correctly', async () => {
renderComponent({
value: mockEmptyBreakpoint,
});
const minInput = screen.getByTestId('min-value-input');
const maxInput = screen.getByTestId('max-value-input');
userEvent.type(minInput, '-10');
userEvent.type(maxInput, '10');
await waitFor(() => {
const saveButton = screen.getByTestId('save-button');
expect(saveButton).toBeEnabled();
});
});
test('should handle decimal values correctly', async () => {
renderComponent({
value: mockEmptyBreakpoint,
});
const minInput = screen.getByTestId('min-value-input');
const maxInput = screen.getByTestId('max-value-input');
userEvent.type(minInput, '0.5');
userEvent.type(maxInput, '99.9');
await waitFor(() => {
const saveButton = screen.getByTestId('save-button');
expect(saveButton).toBeEnabled();
});
});
test('should not show overlap error when editing existing breakpoint', async () => {
const existingBreakpoints: ColorBreakpointType[] = [
{
id: 0,
color: { r: 255, g: 0, b: 0, a: 100 },
minValue: 0,
maxValue: 50,
},
{
id: 1,
color: { r: 0, g: 255, b: 0, a: 100 },
minValue: 100,
maxValue: 200,
},
];
const editingBreakpoint = existingBreakpoints[0];
renderComponent({
colorBreakpoints: existingBreakpoints,
value: editingBreakpoint,
});
const minInput = screen.getByDisplayValue('0');
userEvent.clear(minInput);
userEvent.type(minInput, '10');
await waitFor(() => {
const saveButton = screen.getByTestId('save-button');
expect(saveButton).toBeEnabled();
});
});
});

View File

@@ -0,0 +1,265 @@
/**
* 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 { useState, useMemo } from 'react';
import { Button, Row, Col, InputNumber } from '@superset-ui/core/components';
import { styled, t, validateNumber } from '@superset-ui/core';
import ControlHeader from '../../ControlHeader';
import ColorPickerControl from '../ColorPickerControl';
import {
ColorBreakpointsPopoverControlProps,
ColorType,
ColorBreakpointType,
ErrorMapType,
} from './types';
const ColorBreakpointActionsContainer = styled.div`
margin-top: ${({ theme }) => theme.sizeUnit * 8}px;
display: flex;
justify-content: flex-end;
`;
const StyledRow = styled(Row)`
gap: ${({ theme }) => theme.sizeUnit * 2}px;
`;
const ValuesRow = styled(Row)`
gap: ${({ theme }) => theme.sizeUnit * 2}px;
display: flex;
align-items: flex-end;
`;
const NumberControlsDivider = styled.div`
padding: 6px 0;
`;
const FullWidthInputNumber = styled(InputNumber)`
width: 100%;
`;
const determineErrorMap = (
colorBreakpoint: ColorBreakpointType,
colorBreakpoints: ColorBreakpointType[],
) => {
const errorMap: ErrorMapType = {
minValue: [],
maxValue: [],
color: [],
};
const minValueError = validateNumber(colorBreakpoint.minValue);
if (minValueError) errorMap.minValue.push(minValueError);
const maxValueError = validateNumber(colorBreakpoint.maxValue);
if (maxValueError) errorMap.maxValue.push(maxValueError);
if (minValueError || maxValueError) return errorMap;
const newMinValue = Number(colorBreakpoint.minValue);
const newMaxValue = Number(colorBreakpoint.maxValue);
if (Number.isNaN(newMinValue) || Number.isNaN(newMaxValue)) {
return errorMap;
}
if (newMinValue >= newMaxValue) {
errorMap.minValue.push(
t('Min value should be smaller or equal to max value'),
);
}
const otherBreakpoints = colorBreakpoints.filter(
breakpoint => breakpoint.id !== colorBreakpoint.id,
);
const isBreakpointDuplicate = !!otherBreakpoints?.find(
breakpoint =>
Number(breakpoint.minValue) <= newMaxValue &&
Number(breakpoint.maxValue) >= newMinValue,
);
if (isBreakpointDuplicate) {
const overlapMsg = t('The values overlap other breakpoint values');
errorMap.minValue.push(overlapMsg);
errorMap.maxValue.push(overlapMsg);
}
const validColor =
typeof colorBreakpoint.color === 'object' &&
'r' in colorBreakpoint.color &&
typeof colorBreakpoint.color.r === 'number' &&
'g' in colorBreakpoint.color &&
typeof colorBreakpoint.color.g === 'number' &&
'b' in colorBreakpoint.color &&
typeof colorBreakpoint.color.b === 'number' &&
'a' in colorBreakpoint.color &&
typeof colorBreakpoint.color.a === 'number';
if (!validColor) {
errorMap.color.push(t('Invalid color'));
}
return errorMap;
};
const convertColorBreakpointToNumeric = (
colorBreakpoint: ColorBreakpointType,
) => {
const formattedColorBreakpoint = {
color: colorBreakpoint.color,
minValue: Number(colorBreakpoint.minValue),
maxValue: Number(colorBreakpoint.maxValue),
};
return formattedColorBreakpoint;
};
const DEFAULT_COLOR_BREAKPOINT: ColorBreakpointType = {
id: undefined,
minValue: undefined,
maxValue: undefined,
color: { r: 0, g: 0, b: 0, a: 100 },
};
const ColorBreakpointsPopoverControl = ({
value: initialValue,
onSave,
onClose,
colorBreakpoints,
}: ColorBreakpointsPopoverControlProps) => {
const [colorBreakpoint, setColorBreakpoint] = useState(
initialValue || DEFAULT_COLOR_BREAKPOINT,
);
const useValidationErrors = (
colorBreakpoint: ColorBreakpointType,
colorBreakpoints: ColorBreakpointType[],
) =>
useMemo(
() => determineErrorMap(colorBreakpoint, colorBreakpoints),
[colorBreakpoint, colorBreakpoints],
);
const validationErrors = useValidationErrors(
colorBreakpoint,
colorBreakpoints,
);
const updateColor = (rgb: ColorType) => {
setColorBreakpoint({
...colorBreakpoint,
color: { ...rgb },
});
};
const updateMinValue = (value: number) => {
setColorBreakpoint({
...colorBreakpoint,
minValue: value,
});
};
const updateMaxValue = (value: number) => {
setColorBreakpoint({
...colorBreakpoint,
maxValue: value,
});
};
const containsErrors = Object.values(validationErrors).some(
errors => errors.length > 0,
);
const handleSave = () => {
if (!containsErrors && onSave) {
onSave(convertColorBreakpointToNumeric(colorBreakpoint));
if (onClose) onClose();
}
};
return (
<div role="dialog">
<StyledRow>
<Col flex="1">
<ControlHeader
name="color"
label={t('Color for breakpoint')}
validationErrors={validationErrors.color}
hovered
/>
<ColorPickerControl
value={colorBreakpoint.color}
onChange={updateColor}
data-test="color-picker"
/>
</Col>
</StyledRow>
<ValuesRow>
<Col flex="1">
<ControlHeader
name="min-value"
label={t('Min value')}
validationErrors={validationErrors.minValue}
hovered
/>
<FullWidthInputNumber
value={colorBreakpoint.minValue}
onChange={updateMinValue}
data-test="min-value-input"
/>
</Col>
<NumberControlsDivider>-</NumberControlsDivider>
<Col flex="1">
<ControlHeader
name="max-value"
label={t('Max value')}
validationErrors={validationErrors.maxValue}
hovered
/>
<FullWidthInputNumber
value={colorBreakpoint.maxValue}
onChange={updateMaxValue}
data-test="max-value-input"
/>
</Col>
</ValuesRow>
<ColorBreakpointActionsContainer>
<Button
buttonSize="small"
buttonStyle="secondary"
onClick={onClose}
aria-label={t('Close color breakpoint editor')}
data-test="close-button"
>
{t('Close')}
</Button>
<Button
disabled={containsErrors}
buttonStyle="primary"
buttonSize="small"
onClick={handleSave}
aria-label={t('Save color breakpoint values')}
data-test="save-button"
>
{t('Save')}
</Button>
</ColorBreakpointActionsContainer>
</div>
);
};
export default ColorBreakpointsPopoverControl;

View File

@@ -0,0 +1,236 @@
/**
* 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 { render, screen, userEvent } from 'spec/helpers/testing-library';
import ColorBreakpointPopoverTrigger from './ColorBreakpointPopoverTrigger';
import {
ColorBreakpointType,
ColorBreakpointsPopoverTriggerProps,
} from './types';
const mockBreakpoint: ColorBreakpointType = {
id: 0,
color: { r: 255, g: 0, b: 0, a: 1 },
minValue: 0,
maxValue: 100,
};
// Couldn't test the color select - providing color instead of interacting with the color select component
const mockEmptyBreakpoint: ColorBreakpointType = {
id: 0,
color: { r: 0, g: 0, b: 0, a: 0 },
};
const mockBreakpoints: ColorBreakpointType[] = [mockBreakpoint];
const createProps = (): ColorBreakpointsPopoverTriggerProps => ({
value: mockBreakpoint,
saveColorBreakpoint: jest.fn(),
colorBreakpoints: mockBreakpoints,
});
const renderComponent = (
props: Partial<ColorBreakpointsPopoverTriggerProps> = {},
) =>
render(
<ColorBreakpointPopoverTrigger {...createProps()} {...props}>
Click to add new breakpoint
</ColorBreakpointPopoverTrigger>,
);
describe('ColorBreakpointPopoverTrigger', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('should render', () => {
const { container } = renderComponent();
expect(container).toBeInTheDocument();
});
test('should render children', () => {
renderComponent();
expect(screen.getByText('Click to add new breakpoint')).toBeInTheDocument();
});
test('should render the popover on click when uncontrolled', async () => {
renderComponent();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
const triggerButton = screen.getByText('Click to add new breakpoint');
userEvent.click(triggerButton);
expect(screen.queryByRole('dialog')).toBeInTheDocument();
});
test('should be visible when controlled and visible is true', async () => {
const controlledProps = {
isControlled: true,
visible: true,
toggleVisibility: jest.fn(),
};
renderComponent(controlledProps);
expect(await screen.findByRole('dialog')).toBeInTheDocument();
});
test('should NOT be visible when controlled and visible is false', () => {
const controlledProps = {
isControlled: true,
visible: false,
toggleVisibility: jest.fn(),
};
renderComponent(controlledProps);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
test('should call toggleVisibility when controlled and popover state changes', async () => {
const toggleVisibility = jest.fn();
const controlledProps = {
isControlled: true,
visible: false,
toggleVisibility,
};
renderComponent(controlledProps);
const triggerButton = screen.getByText('Click to add new breakpoint');
await userEvent.click(triggerButton);
expect(toggleVisibility).toHaveBeenCalledWith(true);
});
test('should render popover content with form elements', async () => {
renderComponent();
const triggerButton = screen.getByText('Click to add new breakpoint');
userEvent.click(triggerButton);
expect(screen.getByText('Color for breakpoint')).toBeInTheDocument();
expect(screen.getByText('Min value')).toBeInTheDocument();
expect(screen.getByText('Max value')).toBeInTheDocument();
});
test('should close popover when save is called', async () => {
renderComponent({ value: mockEmptyBreakpoint });
const triggerButton = screen.getByText('Click to add new breakpoint');
userEvent.click(triggerButton);
const minInput = screen.getAllByRole('spinbutton')[0];
const maxInput = screen.getAllByRole('spinbutton')[1];
userEvent.type(minInput, '10');
userEvent.type(maxInput, '90');
const saveButton = screen.getByTestId('save-button');
expect(saveButton).toBeEnabled();
userEvent.click(saveButton);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
test('should close popover when close button is clicked', async () => {
renderComponent();
const triggerButton = screen.getByText('Click to add new breakpoint');
userEvent.click(triggerButton);
const closeButton = screen.getByTestId('close-button');
userEvent.click(closeButton);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
test('should handle undefined value prop', async () => {
renderComponent({ value: undefined });
const triggerButton = screen.getByText('Click to add new breakpoint');
userEvent.click(triggerButton);
expect(screen.queryByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Color for breakpoint')).toBeInTheDocument();
});
test('should handle popover open state changes correctly', async () => {
renderComponent();
const triggerButton = screen.getByText('Click to add new breakpoint');
userEvent.click(triggerButton);
expect(screen.queryByRole('dialog')).toBeInTheDocument();
userEvent.click(triggerButton);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
test('should maintain controlled state when toggleVisibility is provided', async () => {
const toggleVisibility = jest.fn();
const controlledProps = {
isControlled: true,
visible: true,
toggleVisibility,
};
renderComponent(controlledProps);
expect(screen.queryByRole('dialog')).toBeInTheDocument();
const closeButton = screen.getByTestId('close-button');
userEvent.click(closeButton);
expect(toggleVisibility).toHaveBeenCalledWith(false);
});
test('should pass colorBreakpoints to popover content', async () => {
const colorBreakpoints = [
{ id: 0, color: { r: 255, g: 0, b: 0, a: 1 }, minValue: 0, maxValue: 50 },
{
id: 1,
color: { r: 0, g: 255, b: 0, a: 1 },
minValue: 50,
maxValue: 100,
},
];
renderComponent({ colorBreakpoints });
const triggerButton = screen.getByText('Click to add new breakpoint');
userEvent.click(triggerButton);
expect(screen.queryByRole('dialog')).toBeInTheDocument();
});
test('should handle destroyOnHidden prop', async () => {
renderComponent();
const triggerButton = screen.getByText('Click to add new breakpoint');
userEvent.click(triggerButton);
expect(screen.queryByRole('dialog')).toBeInTheDocument();
const closeButton = screen.getByTestId('close-button');
userEvent.click(closeButton);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,62 @@
/**
* 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 { useState } from 'react';
import ControlPopover from '../ControlPopover/ControlPopover';
import { ColorBreakpointsPopoverTriggerProps } from './types';
import ColorBreakpointPopoverControl from './ColorBreakpointPopoverControl';
const ColorBreakpointsPopoverTrigger = ({
value: initialValue,
saveColorBreakpoint,
isControlled,
visible: controlledVisibility,
toggleVisibility,
colorBreakpoints,
...props
}: ColorBreakpointsPopoverTriggerProps) => {
const [isVisible, setIsVisible] = useState(false);
const visible = isControlled ? controlledVisibility : isVisible;
const setVisibility =
isControlled && toggleVisibility ? toggleVisibility : setIsVisible;
const popoverContent = (
<ColorBreakpointPopoverControl
value={initialValue}
colorBreakpoints={colorBreakpoints}
onSave={saveColorBreakpoint}
onClose={() => setVisibility(false)}
/>
);
return (
<ControlPopover
trigger="click"
content={popoverContent}
defaultOpen={visible}
open={visible}
onOpenChange={setVisibility}
destroyOnHidden
>
{props.children}
</ControlPopover>
);
};
export default ColorBreakpointsPopoverTrigger;

View File

@@ -0,0 +1,225 @@
/**
* 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 { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import ColorBreakpointsControl from '.';
import { ColorBreakpointType, ColorBreakpointsControlProps } from './types';
interface Props extends ColorBreakpointsControlProps {
name: string;
label: string;
value: ColorBreakpointType[];
onChange: jest.Mock;
breakpoints: ColorBreakpointType[];
}
const createProps = (): Props => ({
name: 'ColorBreakpointsControl',
label: 'Color Breakpoints',
value: [],
onChange: jest.fn(),
breakpoints: [],
actions: {
setControlValue: jest.fn(),
},
type: 'ColorBreakpointsControl',
});
const renderComponent = (props: Partial<Props> = {}) =>
render(<ColorBreakpointsControl {...createProps()} {...props} />, {
useDnd: true,
});
describe('ColorBreakpointsControl', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render with default props', () => {
renderComponent();
expect(screen.getByText('Click to add new breakpoint')).toBeInTheDocument();
});
test('should render existing breakpoints', () => {
const existingBreakpoint: ColorBreakpointType = {
id: 0,
color: { r: 255, g: 0, b: 0, a: 1 },
minValue: 0,
maxValue: 100,
};
renderComponent({ value: [existingBreakpoint] });
expect(screen.getByText('0 - 100')).toBeInTheDocument();
});
test('should handle empty breakpoints array', () => {
renderComponent({ value: [] });
expect(screen.getByText('Click to add new breakpoint')).toBeInTheDocument();
});
test('should handle multiple breakpoints', () => {
const breakpoints: ColorBreakpointType[] = [
{
id: 0,
color: { r: 255, g: 0, b: 0, a: 1 },
minValue: 0,
maxValue: 50,
},
{
id: 1,
color: { r: 0, g: 255, b: 0, a: 1 },
minValue: 50,
maxValue: 100,
},
];
renderComponent({ value: breakpoints });
expect(screen.getByText('0 - 50')).toBeInTheDocument();
expect(screen.getByText('50 - 100')).toBeInTheDocument();
});
test('should call onChange when component state updates', () => {
const onChange = jest.fn();
renderComponent({ onChange });
expect(onChange).toHaveBeenCalledWith([]);
});
test('should show new breakpoint button when no breakpoints exist', () => {
renderComponent();
const ghostButton = screen.getByText('Click to add new breakpoint');
expect(ghostButton).toBeInTheDocument();
});
test('should handle new breakpoint button click and popover visibility state', async () => {
renderComponent();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
const addButton = screen.getByText('Click to add new breakpoint');
userEvent.click(addButton);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
test('should save new breakpoint and update state', async () => {
const onChange = jest.fn();
renderComponent({ onChange });
const addButton = screen.getByText('Click to add new breakpoint');
userEvent.click(addButton);
const minInput = screen.getByTestId('min-value-input');
const maxInput = screen.getByTestId('max-value-input');
userEvent.type(minInput, '10');
userEvent.type(maxInput, '90');
await waitFor(() => {
const saveButton = screen.getByTestId('save-button');
expect(saveButton).toBeEnabled();
});
const saveButton = screen.getByTestId('save-button');
userEvent.click(saveButton);
expect(onChange).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
minValue: 10,
maxValue: 90,
id: 0,
}),
]),
);
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
test('should remove breakpoint when delete is triggered', async () => {
const existingBreakpoint: ColorBreakpointType = {
id: 0,
color: { r: 255, g: 0, b: 0, a: 1 },
minValue: 0,
maxValue: 100,
};
const onChange = jest.fn();
renderComponent({ value: [existingBreakpoint], onChange });
const removeButton = screen.getByTestId('remove-control-button');
userEvent.click(removeButton);
expect(onChange).toHaveBeenCalledWith([]);
});
test('should edit existing breakpoint when clicked', async () => {
const existingBreakpoint: ColorBreakpointType = {
id: 0,
color: { r: 255, g: 0, b: 0, a: 1 },
minValue: 0,
maxValue: 100,
};
const onChange = jest.fn();
renderComponent({ value: [existingBreakpoint], onChange });
const breakpointOption = screen.getByText('0 - 100');
userEvent.click(breakpointOption);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByDisplayValue('0')).toBeInTheDocument();
expect(screen.getByDisplayValue('100')).toBeInTheDocument();
});
test('should handle DndSelectLabel props correctly', () => {
renderComponent();
const dndSelectLabel = screen
.getByText('Click to add new breakpoint')
.closest('div');
expect(dndSelectLabel).toBeInTheDocument();
});
test('should assign incremental IDs to new breakpoints', async () => {
const onChange = jest.fn();
renderComponent({ onChange });
const addButton = screen.getByText('Click to add new breakpoint');
userEvent.click(addButton);
const minInput = screen.getByTestId('min-value-input');
const maxInput = screen.getByTestId('max-value-input');
userEvent.type(minInput, '0');
userEvent.type(maxInput, '50');
await waitFor(() => {
const saveButton = screen.getByTestId('save-button');
expect(saveButton).toBeEnabled();
});
const saveButton = screen.getByTestId('save-button');
userEvent.click(saveButton);
expect(onChange).toHaveBeenCalledWith([expect.objectContaining({ id: 0 })]);
});
});

View File

@@ -0,0 +1,127 @@
/**
* 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 { useState, useEffect } from 'react';
import { styled, t } from '@superset-ui/core';
import DndSelectLabel from 'src/explore/components/controls/DndColumnSelectControl/DndSelectLabel';
import ColorBreakpointOption from './ColorBreakpointOption';
import { ColorBreakpointType, ColorBreakpointsControlProps } from './types';
import ColorBreakpointPopoverTrigger from './ColorBreakpointPopoverTrigger';
const DEFAULT_COLOR_BREAKPOINTS: ColorBreakpointType[] = [];
const NewColorBreakpointFormatPlaceholder = styled('div')`
position: relative;
width: calc(100% - ${({ theme }) => theme.sizeUnit}px);
bottom: ${({ theme }) => theme.sizeUnit * 4}px;
left: 0;
`;
const ColorBreakpointsControl = ({
onChange,
...props
}: ColorBreakpointsControlProps) => {
const [popoverVisible, setPopoverVisible] = useState(false);
const [colorBreakpoints, setColorBreakpoints] = useState<
ColorBreakpointType[]
>(props?.value ? props?.value : DEFAULT_COLOR_BREAKPOINTS);
useEffect(() => {
onChange?.(colorBreakpoints);
}, [colorBreakpoints, onChange]);
const togglePopover = (visible: boolean) => {
setPopoverVisible(visible);
};
const handleClickGhostButton = () => {
togglePopover(true);
};
const saveColorBreakpoint = (breakpoint: ColorBreakpointType) => {
setColorBreakpoints([
...colorBreakpoints,
{
...breakpoint,
id: colorBreakpoints.length,
},
]);
togglePopover(false);
};
const removeColorBreakpoint = (index: number) => {
const newBreakpoints = [...colorBreakpoints];
newBreakpoints.splice(index, 1);
setColorBreakpoints(newBreakpoints);
};
const editColorBreakpoint = (
breakpoint: ColorBreakpointType,
index: number,
) => {
const newBreakpoints = [...colorBreakpoints];
newBreakpoints[index] = {
...breakpoint,
id: index,
};
setColorBreakpoints(newBreakpoints);
};
const valuesRenderer = () =>
colorBreakpoints.map((breakpoint, index) => (
<ColorBreakpointOption
key={index}
saveColorBreakpoint={(newBreakpoint: ColorBreakpointType) =>
editColorBreakpoint(newBreakpoint, index)
}
breakpoint={breakpoint}
colorBreakpoints={colorBreakpoints}
index={index}
onClose={removeColorBreakpoint}
onShift={() => {}}
/>
));
const ghostButtonText = t('Click to add new breakpoint');
return (
<>
<DndSelectLabel
onDrop={() => {}}
canDrop={() => false}
valuesRenderer={valuesRenderer}
accept={[]}
ghostButtonText={ghostButtonText}
onClickGhostButton={handleClickGhostButton}
{...props}
/>
<ColorBreakpointPopoverTrigger
saveColorBreakpoint={saveColorBreakpoint}
colorBreakpoints={colorBreakpoints}
isControlled
visible={popoverVisible}
toggleVisibility={setPopoverVisible}
>
<NewColorBreakpointFormatPlaceholder />
</ColorBreakpointPopoverTrigger>
</>
);
};
export default ColorBreakpointsControl;

View File

@@ -0,0 +1,76 @@
/**
* 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 { ReactNode } from 'react';
import { OptionValueType } from 'src/explore/components/controls/DndColumnSelectControl/types';
import { ControlComponentProps } from 'src/explore/components/Control';
export interface ColorType {
r: number;
g: number;
b: number;
a: number;
}
export interface ColorBreakpointType {
id?: number;
color?: ColorType;
minValue?: number;
maxValue?: number;
}
export interface ErrorMapType {
color: string[];
minValue: string[];
maxValue: string[];
}
export interface ColorBreakpointsControlProps
extends ControlComponentProps<OptionValueType[]> {
breakpoints: ColorBreakpointType[];
}
export interface ColorBreakpointsPopoverTriggerProps {
description?: string;
hovered?: boolean;
value?: ColorBreakpointType;
children?: ReactNode;
saveColorBreakpoint: (colorBreakpoint: ColorBreakpointType) => void;
isControlled?: boolean;
visible?: boolean;
toggleVisibility?: (visibility: boolean) => void;
colorBreakpoints: ColorBreakpointType[];
}
export interface ColorBreakpointsPopoverControlProps {
description?: string;
hovered?: boolean;
value?: ColorBreakpointType;
onSave?: (colorBreakpoint: ColorBreakpointType) => void;
onClose?: () => void;
colorBreakpoints: ColorBreakpointType[];
}
export interface ColorBreakpointOptionProps {
breakpoint: ColorBreakpointType;
colorBreakpoints: ColorBreakpointType[];
index: number;
saveColorBreakpoint: (colorBreakpoint: ColorBreakpointType) => void;
onClose: (index: number) => void;
onShift: (hoverIndex: number, dragIndex: number) => void;
}

View File

@@ -52,7 +52,7 @@ const ContourOption = ({
const formattedColor = color
? `rgba(${color.r}, ${color.g}, ${color.b}, 1)`
: 'undefined';
: 'rgba(0,0,0,0)';
const formatIsoline = (threshold: number, width: number) =>
`${t('Threshold')}: ${threshold}, ${t('color')}: ${formattedColor}, ${t(

View File

@@ -479,3 +479,30 @@ test('should show missing dataset state', () => {
),
).toBeVisible();
});
test('should show forbidden dataset state', () => {
// @ts-ignore
delete window.location;
// @ts-ignore
window.location = { search: '?slice_id=152' };
const error = {
error_type: 'TABLE_SECURITY_ACCESS_ERROR',
statusText: 'FORBIDDEN',
message: 'You do not have access to the following tables: blocked_table',
extra: {
datasource: 152,
datasource_name: 'forbidden dataset',
},
};
const props = createProps({
datasource: {
...fallbackExploreInitialData.dataset,
extra: {
error,
},
},
});
render(<DatasourceControl {...props} />, { useRedux: true, useRouter: true });
expect(screen.getByText(error.message)).toBeInTheDocument();
expect(screen.getByText(error.statusText)).toBeVisible();
});

View File

@@ -50,6 +50,7 @@ import {
userHasPermission,
isUserAdmin,
} from 'src/dashboard/util/permissionUtils';
import { ErrorMessageWithStackTrace } from 'src/components/ErrorMessage/ErrorMessageWithStackTrace';
import ViewQueryModalFooter from 'src/explore/components/controls/ViewQueryModalFooter';
import ViewQuery from 'src/explore/components/controls/ViewQuery';
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
@@ -281,7 +282,17 @@ class DatasourceControl extends PureComponent {
showSaveDatasetModal,
} = this.state;
const { datasource, onChange, theme } = this.props;
const isMissingDatasource = !datasource?.id;
let extra;
if (datasource?.extra) {
if (typeof datasource.extra === 'string') {
try {
extra = JSON.parse(datasource.extra);
} catch {} // eslint-disable-line no-empty
} else {
extra = datasource.extra; // eslint-disable-line prefer-destructuring
}
}
const isMissingDatasource = !datasource?.id || Boolean(extra?.error);
let isMissingParams = false;
if (isMissingDatasource) {
const datasourceId = getUrlParam(URL_PARAMS.datasourceId);
@@ -386,20 +397,10 @@ class DatasourceControl extends PureComponent {
const { health_check_message: healthCheckMessage } = datasource;
let extra;
if (datasource?.extra) {
if (typeof datasource.extra === 'string') {
try {
extra = JSON.parse(datasource.extra);
} catch {} // eslint-disable-line no-empty
} else {
extra = datasource.extra; // eslint-disable-line prefer-destructuring
}
}
const titleText = isMissingDatasource
? t('Missing dataset')
: getDatasourceTitle(datasource);
const titleText =
isMissingDatasource && !datasource.name
? t('Missing dataset')
: getDatasourceTitle(datasource);
const tooltip = titleText;
@@ -452,31 +453,44 @@ class DatasourceControl extends PureComponent {
)}
{isMissingDatasource && !isMissingParams && (
<div className="error-alert">
<ErrorAlert
type="warning"
errorType={t('Missing dataset')}
descriptionPre={false}
descriptionDetailsCollapsed={false}
descriptionDetails={
<>
<p>
{t(
'The dataset linked to this chart may have been deleted.',
)}
</p>
<p>
<Button
buttonStyle="warning"
onClick={() =>
this.handleMenuItemClick({ key: CHANGE_DATASET })
}
>
{t('Swap dataset')}
</Button>
</p>
</>
}
/>
{extra?.error ? (
<div className="error-alert">
<ErrorMessageWithStackTrace
title={extra.error.statusText || extra.error.message}
subtitle={
extra.error.statusText ? extra.error.message : undefined
}
error={extra.error}
source="explore"
/>
</div>
) : (
<ErrorAlert
type="warning"
errorType={t('Missing dataset')}
descriptionPre={false}
descriptionDetailsCollapsed={false}
descriptionDetails={
<>
<p>
{t(
'The dataset linked to this chart may have been deleted.',
)}
</p>
<p>
<Button
buttonStyle="warning"
onClick={() =>
this.handleMenuItemClick({ key: CHANGE_DATASET })
}
>
{t('Swap dataset')}
</Button>
</p>
</>
}
/>
)}
</div>
)}
{showEditDatasourceModal && (

View File

@@ -90,7 +90,10 @@ const DndFilterSelect = (props: DndFilterSelectProps) => {
let extra = {};
if (datasource?.extra) {
try {
extra = JSON.parse(datasource.extra);
extra =
typeof datasource.extra === 'string'
? JSON.parse(datasource.extra)
: datasource.extra;
} catch {} // eslint-disable-line no-empty
}
return extra;

View File

@@ -56,6 +56,7 @@ import MapViewControl from './MapViewControl/MapViewControl';
import ZoomConfigControl from './ZoomConfigControl/ZoomConfigControl';
import NumberControl from './NumberControl';
import TimeRangeControl from './TimeRangeControl';
import ColorBreakpointsControl from './ColorBreakpointsControl';
const extensionsRegistry = getExtensionsRegistry();
const DateFilterControlExtension = extensionsRegistry.get(
@@ -79,6 +80,7 @@ const controlMap = {
DndFilterSelect,
DndMetricSelect,
FixedOrMetricControl,
ColorBreakpointsControl,
HiddenControl,
LayerConfigsControl,
MapViewControl,

View File

@@ -159,8 +159,8 @@ export const fallbackExploreInitialData: ExplorePageInitialData = {
verbose_map: {},
main_dttm_col: '',
owners: [],
datasource_name: 'missing_datasource',
name: 'missing_datasource',
datasource_name: '',
name: '',
description: null,
},
slice: null,

View File

@@ -69,7 +69,7 @@ export type Datasource = Dataset & {
catalog?: string | null;
schema?: string;
is_sqllab_view?: boolean;
extra?: string;
extra?: string | object;
};
export interface ExplorePageInitialData {

View File

@@ -252,8 +252,9 @@ export type DBReducerActionType =
};
const StyledBtns = styled.div`
margin-bottom: ${({ theme }) => theme.sizeUnit * 3}px;
margin-left: ${({ theme }) => theme.sizeUnit * 3}px;
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.sizeUnit * 5}px;
`;
export function dbReducer(
@@ -702,6 +703,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
// Test Connection logic
const testConnection = () => {
handleClearValidationErrors();
if (!db?.sqlalchemy_uri) {
addDangerToast(t('Please enter a SQLAlchemy URI to test'));
return;
@@ -728,10 +730,12 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
(errorMsg: string) => {
setTestInProgress(false);
addDangerToast(errorMsg);
setHasValidated(false);
},
(errorMsg: string) => {
setTestInProgress(false);
addSuccessToast(errorMsg);
setHasValidated(true);
},
);
};
@@ -761,6 +765,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
const handleClearValidationErrors = useCallback(() => {
setValidationErrors(null);
setHasValidated(false);
clearError();
}, [setValidationErrors, setHasValidated]);
const handleParametersChange = useCallback(
@@ -820,7 +825,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
const onSave = async () => {
let dbConfigExtraExtensionOnSaveError;
setLoading(true);
setHasValidated(false);
dbConfigExtraExtension
?.onSave(extraExtensionComponentState, db)
.then(({ error }: { error: any }) => {
@@ -1166,6 +1171,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
};
const handleBackButtonOnConnect = () => {
handleClearValidationErrors();
if (editNewDb) setHasConnectedDb(false);
if (importingModal) setImportingModal(false);
if (importErrored) {
@@ -1925,14 +1931,15 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
target,
}: {
target: HTMLInputElement;
}) =>
}) => {
setHasValidated(false);
onChange(ActionType.InputChange, {
type: target.type,
name: target.name,
checked: target.checked,
value: target.value,
})
}
});
}}
conf={conf}
testConnection={testConnection}
testInProgress={testInProgress}
@@ -2156,7 +2163,8 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
<Button
data-test="sqla-connect-btn"
buttonStyle="link"
onClick={() =>
onClick={() => {
handleClearValidationErrors();
setDB({
type: ActionType.ConfigMethodChange,
payload: {
@@ -2165,8 +2173,8 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
ConfigurationMethod.SqlalchemyUri,
database_name: db.database_name,
},
})
}
});
}}
css={buttonLinkStyles}
>
{t(

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