Compare commits

..

33 Commits

Author SHA1 Message Date
Maxime Beauchemin
f1f6981b9c Merge branch 'master' into plavacquery-config-map-tiles 2025-07-03 15:33:32 -07:00
Vladislav Korenkov
9f0523977d feat(plugin-chart-echarts): add Gantt Chart plugin (#33716) 2025-07-03 14:23:50 -07:00
Joe Li
cb6342fc73 fix: Update spacing on echart legends (#34018) 2025-07-03 13:33:15 -07:00
dependabot[bot]
5214ee6fd4 chore(deps-dev): update jest requirement from ^30.0.2 to ^30.0.4 in /superset-frontend/plugins/plugin-chart-pivot-table (#34036)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2025-07-03 12:58:28 -07:00
dependabot[bot]
7b3329f315 chore(deps-dev): update @types/lodash requirement from ^4.17.16 to ^4.17.20 in /superset-frontend/plugins/plugin-chart-handlebars (#34037)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2025-07-03 12:57:42 -07:00
Vladislav Korenkov
d1b372f670 fix(chart controls): remove duplicated descriptions for chart controls (#33954) 2025-07-03 16:01:59 -03:00
dependabot[bot]
09c657c899 chore(deps-dev): update @babel/types requirement from ^7.26.9 to ^7.28.0 in /superset-frontend/plugins/plugin-chart-pivot-table (#34038)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2025-07-03 11:44:21 -07:00
dependabot[bot]
24500e99f8 chore(deps): bump ace-builds from 1.43.0 to 1.43.1 in /superset-frontend (#34043)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-03 09:39:22 -07:00
dependabot[bot]
b2a173977e chore(deps): bump mapbox-gl from 2.15.0 to 3.13.0 in /superset-frontend (#34008)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-03 09:38:26 -07:00
dependabot[bot]
8be79f4170 chore(deps-dev): bump @types/lodash from 4.17.13 to 4.17.20 in /superset-websocket (#34035)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-03 09:37:47 -07:00
dependabot[bot]
debaf8d6e9 chore(deps): bump @emotion/styled from 11.14.0 to 11.14.1 in /superset-frontend (#33992)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-03 09:35:41 -07:00
dependabot[bot]
1133d84775 chore(deps-dev): bump @applitools/eyes-storybook from 3.53.4 to 3.55.6 in /superset-frontend (#33987)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-03 09:34:07 -07:00
Maxime Beauchemin
8c4ca60b28 fix(styling): various minor visual tweaks and adjustments (#34031) 2025-07-03 08:55:28 -07:00
dependabot[bot]
90eded1a04 chore(deps-dev): bump prettier from 3.4.2 to 3.6.2 in /superset-websocket (#34033)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-03 22:46:56 +07:00
dependabot[bot]
093ee37aac chore(deps): bump swagger-ui-react from 5.25.3 to 5.26.0 in /docs (#34041)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-03 22:45:37 +07:00
Titan_gamini
23b1fe3b9e fix(dashboard): prevent crash on invalid CSS selectors in CSS templates (#33971)
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
2025-07-03 09:48:09 +03:00
Đỗ Trọng Hải
42288c4784 build(dev-deps): upgrade Jest to major version v30 (#33979) 2025-07-03 11:01:40 +07:00
Vitor Avila
c008190a08 fix: Dashboard native filter fixes (#33958) 2025-07-02 23:08:42 -03:00
dependabot[bot]
2734688f4f chore(deps): bump hot-shots from 10.2.1 to 11.1.0 in /superset-websocket (#34004)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-02 16:36:41 -07:00
dependabot[bot]
e3d8326d81 chore(deps-dev): bump @docusaurus/tsconfig from 3.8.0 to 3.8.1 in /docs (#34003)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-02 16:35:44 -07:00
dependabot[bot]
e1ab27f484 chore(deps): bump ace-builds from 1.41.0 to 1.43.0 in /superset-frontend (#34002)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-02 16:35:24 -07:00
dependabot[bot]
d1d43be9d1 chore(deps): bump swagger-ui-react from 5.25.2 to 5.25.3 in /docs (#34001)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-02 16:35:00 -07:00
dependabot[bot]
f9cec3e366 chore(deps-dev): bump eslint from 9.27.0 to 9.30.0 in /superset-websocket (#34000)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-02 16:34:29 -07:00
dependabot[bot]
f92431176a chore(deps-dev): bump @babel/cli from 7.26.4 to 7.27.2 in /superset-frontend (#33985)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-02 16:31:35 -07:00
dependabot[bot]
c31daf8c92 chore(deps): bump actions/cache from 3 to 4 (#33999)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-02 09:14:28 -07:00
Alexandru Soare
c74fae9663 feat(flag): Added feature_flag for superset security_views (#34023) 2025-07-02 14:31:09 +03:00
LisaHusband
308007f909 fix(handlebars): remove serverPaginationControlSetRow from control pa… (#34016) 2025-07-01 22:38:01 +03:00
dependabot[bot]
9aaab7374e chore(deps): bump antd from 5.25.1 to 5.26.3 in /docs (#33982)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-01 12:13:51 -07:00
Gabriel Torres Ruiz
057218d87f feat: Add confirmation modal for unsaved changes (#33809) 2025-07-01 19:38:51 +03:00
Đỗ Trọng Hải
050ccdcb3d chore: replace querystring usage with native URLSearchParams API (#33967)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2025-07-01 07:17:35 +07:00
Hugo Ferrando Seage
09d975cc3f docs: Fix typo in UPDATING.md regarding translations in version 5.0.0 (#33972) 2025-06-30 23:44:56 +07:00
plavacquery
95b6236ec9 fix(rebase): packagelock 2025-06-24 23:30:17 +02:00
plavacquery
22d12f3b24 feat(deckgl): add OSM tiles 2025-06-24 23:21:23 +02:00
162 changed files with 11014 additions and 10953 deletions

View File

@@ -12,6 +12,10 @@ updates:
# not until React >= 18.0.0
- dependency-name: "storybook"
- dependency-name: "@storybook*"
# JSDOM v30 doesn't play well with Jest v30
# Source: https://jestjs.io/blog#known-issues
# GH thread: https://github.com/jsdom/jsdom/issues/3492
- dependency-name: "jest-environment-jsdom"
directory: "/superset-frontend/"
schedule:
interval: "monthly"

View File

@@ -54,7 +54,7 @@ jobs:
yarn install --immutable
- name: Cache pre-commit environments
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-v2-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}

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,6 +24,7 @@ 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.
@@ -43,7 +44,7 @@ assists people when migrating to a new version.
- [31198](https://github.com/apache/superset/pull/31198) Disallows by default the use of the following ClickHouse functions: "version", "currentDatabase", "hostName".
- [29798](https://github.com/apache/superset/pull/29798) Since 3.1.0, the intial schedule for an alert or report was mistakenly offset by the specified timezone's relation to UTC. The initial schedule should now begin at the correct time.
- [30021](https://github.com/apache/superset/pull/30021) The `dev` layer in our Dockerfile no long includes firefox binaries, only Chromium to reduce bloat/docker-build-time.
- [30099](https://github.com/apache/superset/pull/30099) Translations are no longer included in the default docker image builds. If your environment requires translations, you'll want to set the docker build arg `BUILD_TRANSACTION=true`.
- [30099](https://github.com/apache/superset/pull/30099) Translations are no longer included in the default docker image builds. If your environment requires translations, you'll want to set the docker build arg `BUILD_TRANSLATIONS=true`.
- [31262](https://github.com/apache/superset/pull/31262) NOTE: deprecated `pylint` in favor of `ruff` as our only python linter. Only affect development workflows positively (not the release itself). It should cover most important rules, be much faster, but some things linting rules that were enforced before may not be enforce in the exact same way as before.
- [31173](https://github.com/apache/superset/pull/31173) Modified `fetch_csrf_token` to align with HTTP standards, particularly regarding how cookies are handled. If you encounter any issues related to CSRF functionality, please report them as a new issue and reference this PR for context.
- [31413](https://github.com/apache/superset/pull/31413) Enable the DATE_FORMAT_IN_EMAIL_SUBJECT feature flag to allow users to specify a date format for the email subject, which will then be replaced with the actual date.
@@ -55,6 +56,7 @@ 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

@@ -0,0 +1,78 @@
---
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

@@ -283,28 +283,3 @@ You are talking about dependency CVEs: identified vulnerabilities in software th
We address these dependency CVEs as best we can by regularly updating our dependencies to newer versions. We use bots to assist with that and cheerfully welcome pull requests from humans that fix dependency CVEs.
The Superset [security team](https://superset.apache.org/docs/security/#reporting-security-vulnerabilities) focuses primarily on vulnerabilities _in Superset itself_. See our [CVEs page](https://superset.apache.org/docs/security/cves) for a list of past Superset CVEs.
## How are dashboard filters broadcasted?
In Superset, **filters on a dashboard are broadcasted to other charts by default** based on matching **column names**, even across different datasets.
### Default behavior
When you add a filter to a dashboard (e.g. selecting `country = Canada`), it will **automatically apply to all charts** that contain a column named `country`, regardless of which dataset they use. This default setting is labeled **"All panels"** in the filters configuration modal.
If two datasets both have a `country` column, the filter will be applied to both. Superset assumes the shared column name reflects the same semantic dimension.
### Things to watch out for
- Superset only matches by **column name**, not content. If one dataset uses `country = Canada` and another uses `country = CA`, the filter might produce **invalid or empty results**.
- This can lead to confusion or broken dashboards unless the datasets are aligned and filterable by the same values.
- When building dashboards that mix datasets, consider how your dimensions are named and whether their values match.
### Controlling filter scope
To manage this, open the **filter configuration modal**, and go to the **“Scoping”** tab. From there, you can:
- Limit the filter to specific charts.
- Avoid broadcasting to charts using datasets with incompatible dimension values.
- See exactly which charts will be affected.
This behavior is powerful, but it does require some care. Align your dataset schemas and be deliberate with scoping to avoid unexpected cross-chart filtering.

View File

@@ -26,7 +26,7 @@
"@emotion/styled": "^10.0.27",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@superset-ui/style": "^0.14.23",
"antd": "^5.25.1",
"antd": "^5.26.3",
"docusaurus-plugin-less": "^2.0.2",
"less": "^4.3.0",
"less-loader": "^12.3.0",
@@ -35,11 +35,11 @@
"react-dom": "^18.3.1",
"react-github-btn": "^1.4.0",
"react-svg-pan-zoom": "^3.13.1",
"swagger-ui-react": "^5.25.2"
"swagger-ui-react": "^5.26.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.8.1",
"@docusaurus/tsconfig": "^3.8.0",
"@docusaurus/tsconfig": "^3.8.1",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",

View File

@@ -158,10 +158,10 @@
"@jridgewell/gen-mapping" "^0.3.5"
"@jridgewell/trace-mapping" "^0.3.24"
"@ant-design/colors@^7.0.0", "@ant-design/colors@^7.2.0":
version "7.2.0"
resolved "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.0.tgz"
integrity sha512-bjTObSnZ9C/O8MB/B4OUtd/q9COomuJAR2SYfhxLyHvCKn4EKwCN3e+fWGMo7H5InAyV0wL17jdE9ALrdOW/6A==
"@ant-design/colors@^7.0.0", "@ant-design/colors@^7.2.1":
version "7.2.1"
resolved "https://registry.yarnpkg.com/@ant-design/colors/-/colors-7.2.1.tgz#3bbc1c6c18550020d1622a0067ff03492318df98"
integrity sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==
dependencies:
"@ant-design/fast-color" "^2.0.6"
@@ -1084,15 +1084,7 @@
"@babel/plugin-transform-modules-commonjs" "^7.26.3"
"@babel/plugin-transform-typescript" "^7.27.0"
"@babel/runtime-corejs3@^7.20.7", "@babel/runtime-corejs3@^7.22.15", "@babel/runtime-corejs3@^7.25.9", "@babel/runtime-corejs3@^7.26.10":
version "7.27.0"
resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.27.0.tgz#c766df350ec7a2caf3ed64e3659b100954589413"
integrity sha512-UWjX6t+v+0ckwZ50Y5ShZLnlk95pP5MyW/pon9tiYzl3+18pkTHTFNTKr7rQbfRXPkowt2QAn30o1b6oswszew==
dependencies:
core-js-pure "^3.30.2"
regenerator-runtime "^0.14.0"
"@babel/runtime-corejs3@^7.27.1":
"@babel/runtime-corejs3@^7.20.7", "@babel/runtime-corejs3@^7.22.15", "@babel/runtime-corejs3@^7.25.9", "@babel/runtime-corejs3@^7.26.10", "@babel/runtime-corejs3@^7.27.1":
version "7.27.6"
resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.27.6.tgz#97644153808a62898e7c05f3361501417db3c48b"
integrity sha512-vDVrlmRAY8z9Ul/HxT+8ceAru95LQgkSKiXkSYZvqtbkPSfhZJgpRp45Cldbh1GJ1kxzQkI70AqyrTI58KpaWQ==
@@ -1965,10 +1957,10 @@
fs-extra "^11.1.1"
tslib "^2.6.0"
"@docusaurus/tsconfig@^3.8.0":
version "3.8.0"
resolved "https://registry.yarnpkg.com/@docusaurus/tsconfig/-/tsconfig-3.8.0.tgz#ea7ee0917e1562cf0a6e95e049c42f1f61351f32"
integrity sha512-utLl48nNjSYBoq47RKukZ9fPLEX3nJWThzrujb0ndQQ1jc/gh4RhTRaAqItH9nImnsgGKmLMnyoMBpfGmoop+w==
"@docusaurus/tsconfig@^3.8.1":
version "3.8.1"
resolved "https://registry.yarnpkg.com/@docusaurus/tsconfig/-/tsconfig-3.8.1.tgz#a1f7daadfc93455289200647f4ee10cdca540f7b"
integrity sha512-XBWCcqhRHhkhfolnSolNL+N7gj3HVE3CoZVqnVjfsMzCoOsuQw2iCLxVVHtO+rePUUfouVZHURDgmqIySsF66A==
"@docusaurus/types@3.8.1":
version "3.8.1"
@@ -2443,10 +2435,10 @@
classnames "^2.3.2"
rc-util "^5.24.4"
"@rc-component/trigger@^2.0.0", "@rc-component/trigger@^2.1.1", "@rc-component/trigger@^2.2.6":
version "2.2.6"
resolved "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.2.6.tgz"
integrity sha512-/9zuTnWwhQ3S3WT1T8BubuFTT46kvnXgaERR9f4BTKyn61/wpf/BvbImzYBubzJibU707FxwbKszLlHjcLiv1Q==
"@rc-component/trigger@^2.0.0", "@rc-component/trigger@^2.1.1", "@rc-component/trigger@^2.2.7":
version "2.2.7"
resolved "https://registry.yarnpkg.com/@rc-component/trigger/-/trigger-2.2.7.tgz#a2b97ecbb93280a3c424e51fa415b371b355d76a"
integrity sha512-Qggj4Z0AA2i5dJhzlfFSmg1Qrziu8dsdHOihROL5Kl18seO2Eh/ZaTYt2c8a/CyGaTChnFry7BEYew1+/fhSbA==
dependencies:
"@babel/runtime" "^7.23.2"
"@rc-component/portal" "^1.1.0"
@@ -4017,12 +4009,12 @@ ansi-styles@^6.1.0:
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
antd@^5.25.1:
version "5.25.1"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.25.1.tgz#859b419a18d113492304ccd66c29074a71902241"
integrity sha512-4KC7KuPCjr0z3Vuw9DsF+ceqJaPLbuUI3lOX1sY8ix25ceamp+P8yxOmk3Y2JHCD2ZAhq+5IQ/DTJRN2adWYKQ==
antd@^5.26.3:
version "5.26.3"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.26.3.tgz#cbbb7e1b48a972dc7b6ee8b6948f51cc91c263f8"
integrity sha512-M/s9Q39h/+G7AWnS6fbNxmAI9waTH4ti022GVEXBLq2j810V1wJ3UOQps13nEilzDNcyxnFN/EIbqIgS7wSYaA==
dependencies:
"@ant-design/colors" "^7.2.0"
"@ant-design/colors" "^7.2.1"
"@ant-design/cssinjs" "^1.23.0"
"@ant-design/cssinjs-utils" "^1.1.3"
"@ant-design/fast-color" "^2.0.6"
@@ -4033,7 +4025,7 @@ antd@^5.25.1:
"@rc-component/mutate-observer" "^1.1.0"
"@rc-component/qrcode" "~1.0.0"
"@rc-component/tour" "~1.15.1"
"@rc-component/trigger" "^2.2.6"
"@rc-component/trigger" "^2.2.7"
classnames "^2.5.1"
copy-to-clipboard "^3.3.3"
dayjs "^1.11.11"
@@ -4041,7 +4033,7 @@ antd@^5.25.1:
rc-checkbox "~3.5.0"
rc-collapse "~3.9.0"
rc-dialog "~9.6.0"
rc-drawer "~7.2.0"
rc-drawer "~7.3.0"
rc-dropdown "~4.2.1"
rc-field-form "~2.7.0"
rc-image "~7.12.0"
@@ -4057,17 +4049,17 @@ antd@^5.25.1:
rc-rate "~2.13.1"
rc-resize-observer "^1.4.3"
rc-segmented "~2.7.0"
rc-select "~14.16.7"
rc-select "~14.16.8"
rc-slider "~11.1.8"
rc-steps "~6.0.1"
rc-switch "~4.1.0"
rc-table "~7.50.4"
rc-table "~7.51.1"
rc-tabs "~15.6.1"
rc-textarea "~1.10.0"
rc-tooltip "~6.4.0"
rc-tree "~5.13.1"
rc-tree-select "~5.27.0"
rc-upload "~4.9.0"
rc-upload "~4.9.2"
rc-util "^5.44.4"
scroll-into-view-if-needed "^3.1.0"
throttle-debounce "^5.0.2"
@@ -10377,10 +10369,10 @@ rc-dialog@~9.6.0:
rc-motion "^2.3.0"
rc-util "^5.21.0"
rc-drawer@~7.2.0:
version "7.2.0"
resolved "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.2.0.tgz"
integrity sha512-9lOQ7kBekEJRdEpScHvtmEtXnAsy+NGDXiRWc2ZVC7QXAazNVbeT4EraQKYwCME8BJLa8Bxqxvs5swwyOepRwg==
rc-drawer@~7.3.0:
version "7.3.0"
resolved "https://registry.yarnpkg.com/rc-drawer/-/rc-drawer-7.3.0.tgz#1bb5fe5f9da38b6a2b2a7dffc9fcb647252a328f"
integrity sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==
dependencies:
"@babel/runtime" "^7.23.9"
"@rc-component/portal" "^1.1.1"
@@ -10552,7 +10544,7 @@ rc-segmented@~2.7.0:
rc-motion "^2.4.4"
rc-util "^5.17.0"
rc-select@~14.16.2, rc-select@~14.16.7:
rc-select@~14.16.2, rc-select@~14.16.8:
version "14.16.8"
resolved "https://registry.yarnpkg.com/rc-select/-/rc-select-14.16.8.tgz#78e6782f1ccc1f03d9003bc3effa4ed609d29a97"
integrity sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==
@@ -10592,10 +10584,10 @@ rc-switch@~4.1.0:
classnames "^2.2.1"
rc-util "^5.30.0"
rc-table@~7.50.4:
version "7.50.4"
resolved "https://registry.yarnpkg.com/rc-table/-/rc-table-7.50.4.tgz#687b5bf76d1a94168f75481cbc83be9442010432"
integrity sha512-Y+YuncnQqoS5e7yHvfvlv8BmCvwDYDX/2VixTBEhkMDk9itS9aBINp4nhzXFKiBP/frG4w0pS9d9Rgisl0T1Bw==
rc-table@~7.51.1:
version "7.51.1"
resolved "https://registry.yarnpkg.com/rc-table/-/rc-table-7.51.1.tgz#cd69ae3262d3b61e4c93c979c12786906e944691"
integrity sha512-5iq15mTHhvC42TlBLRCoCBLoCmGlbRZAlyF21FonFnS/DIC8DeRqnmdyVREwt2CFbPceM0zSNdEeVfiGaqYsKw==
dependencies:
"@babel/runtime" "^7.10.1"
"@rc-component/context" "^1.4.0"
@@ -10660,10 +10652,10 @@ rc-tree@~5.13.0, rc-tree@~5.13.1:
rc-util "^5.16.1"
rc-virtual-list "^3.5.1"
rc-upload@~4.9.0:
version "4.9.0"
resolved "https://registry.yarnpkg.com/rc-upload/-/rc-upload-4.9.0.tgz#911963ab5a0b538c743765371c05e2de9e3f5436"
integrity sha512-pAzlPnyiFn1GCtEybEG2m9nXNzQyWXqWV2xFYCmDxjN9HzyjS5Pz2F+pbNdYw8mMJsixLEKLG0wVy9vOGxJMJA==
rc-upload@~4.9.2:
version "4.9.2"
resolved "https://registry.yarnpkg.com/rc-upload/-/rc-upload-4.9.2.tgz#297f52fd1b1c2a4b570c3e42444609b7530531bb"
integrity sha512-nHx+9rbd1FKMiMRYsqQ3NkXUv7COHPBo3X1Obwq9SWS6/diF/A0aJ5OHubvwUAIDs+4RMleljV0pcrNUc823GQ==
dependencies:
"@babel/runtime" "^7.18.3"
classnames "^2.2.5"
@@ -11998,10 +11990,10 @@ swagger-client@^3.35.5:
ramda "^0.30.1"
ramda-adjunct "^5.1.0"
swagger-ui-react@^5.25.2:
version "5.25.2"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.25.2.tgz#27e8570c7225a0beffcfae26b569c8dd1274c545"
integrity sha512-derGtL7NKvF0w4XIULgiWmS1y3EMEVQAJwLVLTAzVo6/ilLhwP9HCz2yjkVVHzK58gvOzrhO3DKk/Effpr5LJw==
swagger-ui-react@^5.26.0:
version "5.26.0"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.26.0.tgz#b15a903d556cc0ec2a56a969beb9d5bc9ea52910"
integrity sha512-4e6bP9bdJyh+SqQW0lxulPn/SDno4+oWrKXsuon5Z9kjtV0zeoWEJ1c70Qxp8kN/c3caFwec8OyxDNhvo14pkw==
dependencies:
"@babel/runtime-corejs3" "^7.27.1"
"@scarf/scarf" "=1.4.0"

View File

@@ -36,3 +36,6 @@ 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

@@ -104,6 +104,7 @@ flask==2.3.3
# flask-babel
# flask-caching
# flask-compress
# flask-cors
# flask-jwt-extended
# flask-limiter
# flask-login
@@ -119,6 +120,8 @@ flask-caching==2.3.1
# via apache-superset (pyproject.toml)
flask-compress==1.17
# via apache-superset (pyproject.toml)
flask-cors==4.0.2
# via apache-superset (pyproject.toml)
flask-jwt-extended==4.7.1
# via flask-appbuilder
flask-limiter==3.12

View File

@@ -69,6 +69,10 @@ const restrictedImportsRules = {
message:
'Please use the theme directly from the ThemeProvider rather than importing supersetTheme.',
},
'no-query-string': {
name: 'query-string',
message: 'Please use the URLSearchParams API instead of query-string.',
},
};
module.exports = {
@@ -82,9 +86,7 @@ module.exports = {
],
parser: '@babel/eslint-parser',
parserOptions: {
ecmaFeatures: {
experimentalObjectRestSpread: true,
},
ecmaVersion: 2018,
},
env: {
browser: true,
@@ -123,6 +125,11 @@ module.exports = {
'react-prefer-function-component',
'prettier',
],
// Add this TS ESlint rule in separate `rules` section to avoid breakages with JS/TS files in /cypress-base.
// TODO(hainenber): merge it to below `rules` section.
rules: {
'@typescript-eslint/prefer-optional-chain': 'error',
},
overrides: [
{
files: ['*.ts', '*.tsx'],
@@ -156,7 +163,7 @@ module.exports = {
'@typescript-eslint/no-non-null-assertion': 0, // disabled temporarily
'@typescript-eslint/explicit-function-return-type': 0,
'@typescript-eslint/explicit-module-boundary-types': 0, // re-enable up for discussion
'@typescript-eslint/prefer-optional-chain': 2,
'@typescript-eslint/no-unused-vars': 'warn', // downgrade to Warning severity for Jest v30 upgrade
camelcase: 0,
'class-methods-use-this': 0,
'func-names': 0,
@@ -391,6 +398,7 @@ module.exports = {
},
},
],
// eslint-disable-next-line no-dupe-keys
rules: {
'theme-colors/no-literal-colors': 'error',
'icons/no-fa-icons-usage': 'error',

View File

@@ -21,30 +21,21 @@ import qs from 'querystring';
import {
dashboardView,
nativeFilters,
exploreView,
dataTestChartName,
} from 'cypress/support/directories';
import {
addCountryNameFilter,
addParentFilterWithValue,
applyAdvancedTimeRangeFilterOnDashboard,
applyNativeFilterValueWithIndex,
cancelNativeFilterSettings,
checkNativeFilterTooltip,
clickOnAddFilterInModal,
collapseFilterOnLeftPanel,
deleteNativeFilter,
enterNativeFilterEditModal,
expandFilterOnLeftPanel,
fillNativeFilterForm,
getNativeFilterPlaceholderWithIndex,
inputNativeFilterDefaultValue,
saveNativeFilterSettings,
nativeFilterTooltips,
undoDeleteNativeFilter,
validateFilterContentOnDashboard,
valueNativeFilterOptions,
validateFilterNameOnDashboard,
testItems,
WORLD_HEALTH_CHARTS,
@@ -55,19 +46,19 @@ import {
visitDashboard,
} from './shared_dashboard_functions';
function selectFilter(index: number) {
cy.get("[data-test='filter-title-container'] [draggable='true']")
.eq(index)
.click();
}
// function selectFilter(index: number) {
// cy.get("[data-test='filter-title-container'] [draggable='true']")
// .eq(index)
// .click();
// }
function closeFilterModal() {
cy.get('body').then($body => {
if ($body.find('[data-test="native-filter-modal-cancel-button"]').length) {
cy.getBySel('native-filter-modal-cancel-button').click();
}
});
}
// function closeFilterModal() {
// cy.get('body').then($body => {
// if ($body.find('[data-test="native-filter-modal-cancel-button"]').length) {
// cy.getBySel('native-filter-modal-cancel-button').click();
// }
// });
// }
describe('Native filters', () => {
describe('Nativefilters initial state not required', () => {

View File

@@ -16,37 +16,29 @@
* specific language governing permissions and limitations
* under the License.
*/
import qs from 'querystring';
import {
dashboardView,
nativeFilters,
exploreView,
dataTestChartName,
} from 'cypress/support/directories';
import {
addCountryNameFilter,
addParentFilterWithValue,
applyAdvancedTimeRangeFilterOnDashboard,
applyNativeFilterValueWithIndex,
cancelNativeFilterSettings,
checkNativeFilterTooltip,
clickOnAddFilterInModal,
collapseFilterOnLeftPanel,
deleteNativeFilter,
enterNativeFilterEditModal,
expandFilterOnLeftPanel,
fillNativeFilterForm,
getNativeFilterPlaceholderWithIndex,
inputNativeFilterDefaultValue,
saveNativeFilterSettings,
nativeFilterTooltips,
undoDeleteNativeFilter,
validateFilterContentOnDashboard,
valueNativeFilterOptions,
validateFilterNameOnDashboard,
testItems,
WORLD_HEALTH_CHARTS,
} from './utils';
import {
prepareDashboardFilters,

View File

@@ -18,7 +18,6 @@
*/
import '@cypress/code-coverage/support';
import '@applitools/eyes-cypress/commands';
import failOnConsoleError from 'cypress-fail-on-console-error';
import { expect } from 'chai';
import rison from 'rison';

View File

@@ -10,7 +10,7 @@
"allowJs": true,
"noEmit": true
},
"files": ["cypress/support/index.d.ts", "./node_modules/@applitools/eyes-cypress/eyes-index.d.ts"],
"files": ["cypress/support/index.d.ts", "./node_modules/@applitools/eyes-cypress/types/index.d.ts"],
"include": ["cypress/**/*.ts", "./cypress.config.ts"],
"exclude": ["node_modules"]
}

View File

@@ -37,6 +37,7 @@ module.exports = {
setupFilesAfterEnv: ['<rootDir>/spec/helpers/setup.ts'],
snapshotSerializers: ['@emotion/jest/serializer'],
testEnvironmentOptions: {
globalsCleanup: true,
url: 'http://localhost',
},
collectCoverageFrom: [

File diff suppressed because it is too large Load Diff

View File

@@ -84,7 +84,7 @@
"dependencies": {
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.3.0",
"@emotion/styled": "^11.14.1",
"@reduxjs/toolkit": "^1.9.3",
"@rjsf/core": "^5.21.1",
"@rjsf/utils": "^5.24.3",
@@ -154,7 +154,7 @@
"json-stringify-pretty-compact": "^2.0.0",
"lodash": "^4.17.21",
"luxon": "^3.5.0",
"mapbox-gl": "^2.10.0",
"mapbox-gl": "^3.13.0",
"markdown-to-jsx": "^7.7.4",
"match-sorter": "^6.3.4",
"memoize-one": "^5.2.1",
@@ -164,8 +164,6 @@
"ol": "^7.5.2",
"polished": "^4.3.1",
"prop-types": "^15.8.1",
"query-string": "^6.13.7",
"rc-trigger": "^5.3.4",
"re-resizable": "^6.10.1",
"react": "^17.0.2",
"react-checkbox-tree": "^1.8.0",
@@ -208,8 +206,8 @@
"yargs": "^17.7.2"
},
"devDependencies": {
"@applitools/eyes-storybook": "^3.50.9",
"@babel/cli": "^7.22.6",
"@applitools/eyes-storybook": "^3.55.6",
"@babel/cli": "^7.27.2",
"@babel/compat-data": "^7.26.8",
"@babel/core": "^7.26.0",
"@babel/eslint-parser": "^7.25.9",
@@ -259,7 +257,6 @@
"@types/node": "^22.12.0",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@types/react-gravatar": "^2.6.14",
"@types/react-json-tree": "^0.6.11",
"@types/react-loadable": "^5.5.11",
"@types/react-redux": "^7.1.10",
@@ -276,17 +273,16 @@
"@types/sinon": "^17.0.3",
"@types/testing-library__jest-dom": "^5.14.9",
"@types/tinycolor2": "^1.4.3",
"@types/yargs": "12 - 18",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"babel-jest": "^29.7.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"babel-jest": "^30.0.2",
"babel-loader": "^10.0.0",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"babel-plugin-typescript-to-proptypes": "^2.0.0",
"cheerio": "1.0.0-rc.10",
"copy-webpack-plugin": "^12.0.2",
"copy-webpack-plugin": "^13.0.0",
"cross-env": "^7.0.3",
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.2",
@@ -316,9 +312,9 @@
"history": "^5.3.0",
"html-webpack-plugin": "^5.6.3",
"imports-loader": "^5.0.0",
"jest": "^29.7.0",
"jest": "^30.0.2",
"jest-environment-jsdom": "^29.7.0",
"jest-html-reporter": "^3.10.2",
"jest-html-reporter": "^4.3.0",
"jest-websocket-mock": "^2.5.0",
"jsdom": "^26.0.0",
"lerna": "^8.2.1",
@@ -337,11 +333,11 @@
"storybook": "8.1.11",
"style-loader": "^4.0.0",
"thread-loader": "^4.0.4",
"ts-jest": "^29.2.5",
"ts-jest": "^29.4.0",
"ts-loader": "^9.5.1",
"tscw-config": "^1.1.2",
"tsx": "^4.19.2",
"typescript": "5.1.6",
"typescript": "5.4.5",
"vm-browserify": "^1.1.2",
"webpack": "^5.98.0",
"webpack-bundle-analyzer": "^4.10.1",

View File

@@ -36,7 +36,7 @@
"devDependencies": {
"cross-env": "^7.0.3",
"fs-extra": "^11.2.0",
"jest": "^29.7.0",
"jest": "^30.0.2",
"yeoman-test": "^8.3.0"
},
"engines": {

View File

@@ -27,10 +27,9 @@ import {
import { PostProcessingFactory } from './types';
import { extractExtraMetrics } from './utils';
export const sortOperator: PostProcessingFactory<PostProcessingSort> = (
formData,
queryObject,
) => {
export const sortOperator: PostProcessingFactory<
PostProcessingSort
> = formData => {
// the sortOperator only used in the barchart v2
const sortableLabels = [
getXAxisLabel(formData),

View File

@@ -29,6 +29,7 @@ export const TITLE_POSITION_OPTIONS: [string, string][] = [
['Left', t('Left')],
['Top', t('Top')],
];
export const titleControls: ControlPanelSectionConfig = {
label: t('Chart Title'),
tabOverride: 'customize',
@@ -43,7 +44,6 @@ export const titleControls: ControlPanelSectionConfig = {
label: t('X Axis Title'),
renderTrigger: true,
default: '',
description: t('Changing this control takes effect instantly'),
},
},
],
@@ -58,7 +58,6 @@ export const titleControls: ControlPanelSectionConfig = {
renderTrigger: true,
default: TITLE_MARGIN_OPTIONS[0],
choices: formatSelectOptions(TITLE_MARGIN_OPTIONS),
description: t('Changing this control takes effect instantly'),
},
},
],
@@ -71,7 +70,6 @@ export const titleControls: ControlPanelSectionConfig = {
label: t('Y Axis Title'),
renderTrigger: true,
default: '',
description: t('Changing this control takes effect instantly'),
},
},
],
@@ -86,7 +84,6 @@ export const titleControls: ControlPanelSectionConfig = {
renderTrigger: true,
default: TITLE_MARGIN_OPTIONS[1],
choices: formatSelectOptions(TITLE_MARGIN_OPTIONS),
description: t('Changing this control takes effect instantly'),
},
},
],
@@ -101,7 +98,6 @@ export const titleControls: ControlPanelSectionConfig = {
renderTrigger: true,
default: TITLE_POSITION_OPTIONS[0][0],
choices: TITLE_POSITION_OPTIONS,
description: t('Changing this control takes effect instantly'),
},
},
],

View File

@@ -17,7 +17,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { QueryColumn, t, validateNonEmpty } from '@superset-ui/core';
import {
GenericDataType,
QueryColumn,
t,
validateNonEmpty,
} from '@superset-ui/core';
import {
ExtraControlProps,
SharedControlConfig,
@@ -52,6 +57,19 @@ type Control = {
* feature flags are set and when they're checked.
*/
function filterOptions(
options: (ColumnMeta | QueryColumn)[],
allowedDataTypes?: GenericDataType[],
) {
if (!allowedDataTypes) {
return options;
}
return options.filter(
o =>
o.type_generic !== undefined && allowedDataTypes.includes(o.type_generic),
);
}
export const dndGroupByControl: SharedControlConfig<
'DndColumnSelect' | 'SelectControl',
ColumnMeta
@@ -81,14 +99,20 @@ export const dndGroupByControl: SharedControlConfig<
const newState: ExtraControlProps = {};
const { datasource } = state;
if (datasource?.columns[0]?.hasOwnProperty('groupby')) {
const options = (datasource as Dataset).columns.filter(c => c.groupby);
const options = filterOptions(
(datasource as Dataset).columns.filter(c => c.groupby),
controlState?.allowedDataTypes,
);
if (controlState?.includeTime) {
options.unshift(DATASET_TIME_COLUMN_OPTION);
}
newState.options = options;
newState.savedMetrics = (datasource as Dataset).metrics || [];
} else {
const options = (datasource?.columns as QueryColumn[]) || [];
const options = filterOptions(
(datasource?.columns as QueryColumn[]) || [],
controlState?.allowedDataTypes,
);
if (controlState?.includeTime) {
options.unshift(QUERY_TIME_COLUMN_OPTION);
}
@@ -177,6 +201,19 @@ export const dndAdhocMetricControl: typeof dndAdhocMetricsControl = {
),
};
export const dndTooltipColumnsControl: typeof dndColumnsControl = {
...dndColumnsControl,
label: t('Tooltip (columns)'),
description: t('Columns to show in the tooltip.'),
};
export const dndTooltipMetricsControl: typeof dndAdhocMetricsControl = {
...dndAdhocMetricsControl,
label: t('Tooltip (metrics)'),
description: t('Metrics to show in the tooltip.'),
validators: [],
};
export const dndAdhocMetricControl2: typeof dndAdhocMetricControl = {
...dndAdhocMetricControl,
label: t('Right Axis Metric'),

View File

@@ -45,6 +45,7 @@ import {
isDefined,
NO_TIME_RANGE,
validateMaxValue,
getColumnLabel,
} from '@superset-ui/core';
import {
@@ -82,6 +83,8 @@ import {
dndSeriesControl,
dndAdhocMetricControl2,
dndXAxisControl,
dndTooltipColumnsControl,
dndTooltipMetricsControl,
} from './dndControls';
const categoricalSchemeRegistry = getCategoricalSchemeRegistry();
@@ -373,6 +376,14 @@ const temporal_columns_lookup: SharedControlConfig<'HiddenControl'> = {
),
};
const zoomable: SharedControlConfig<'CheckboxControl'> = {
type: 'CheckboxControl',
label: t('Data Zoom'),
default: false,
renderTrigger: true,
description: t('Enable data zooming controls'),
};
const sort_by_metric: SharedControlConfig<'CheckboxControl'> = {
type: 'CheckboxControl',
label: t('Sort by metric'),
@@ -381,6 +392,26 @@ const sort_by_metric: SharedControlConfig<'CheckboxControl'> = {
),
};
const order_by_cols: SharedControlConfig<'SelectControl'> = {
type: 'SelectControl',
label: t('Ordering'),
description: t('Order results by selected columns'),
multi: true,
default: [],
shouldMapStateToProps: () => true,
mapStateToProps: ({ datasource }) => ({
choices: (datasource?.columns || [])
.map(col =>
[true, false].map(asc => [
JSON.stringify([col.column_name, asc]),
`${getColumnLabel(col.column_name)} [${asc ? 'asc' : 'desc'}]`,
]),
)
.flat(),
}),
resetOnHide: false,
};
export default {
metrics: dndAdhocMetricsControl,
metric: dndAdhocMetricControl,
@@ -392,6 +423,8 @@ export default {
secondary_metric: dndSecondaryMetricControl,
groupby: dndGroupByControl,
columns: dndColumnsControl,
tooltip_columns: dndTooltipColumnsControl,
tooltip_metrics: dndTooltipMetricsControl,
granularity,
granularity_sqla: dndGranularitySqlaControl,
time_grain_sqla,
@@ -417,8 +450,10 @@ export default {
legacy_order_by: dndSortByControl,
truncate_metric,
x_axis: dndXAxisControl,
zoomable,
show_empty_columns,
temporal_columns_lookup,
currency_format,
sort_by_metric,
order_by_cols,
};

View File

@@ -29,7 +29,7 @@
"@fontsource/fira-code": "^5.0.18",
"@fontsource/inter": "^5.0.20",
"@types/json-bigint": "^1.0.4",
"ace-builds": "^1.41.0",
"ace-builds": "^1.43.1",
"brace": "^0.11.1",
"classnames": "^2.2.5",
"csstype": "^3.1.3",
@@ -65,7 +65,7 @@
"xss": "^1.0.14"
},
"devDependencies": {
"@emotion/styled": "^11.3.0",
"@emotion/styled": "^11.14.1",
"@types/d3-format": "^3.0.4",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-scale": "^2.1.1",
@@ -88,7 +88,7 @@
"antd": "^5.24.6",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@emotion/styled": "^11.14.1",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^12.1.5",

View File

@@ -31,6 +31,7 @@ export enum VizType {
Compare = 'compare',
CountryMap = 'country_map',
Funnel = 'funnel',
Gantt = 'gantt_chart',
Gauge = 'gauge_chart',
Graph = 'graph_chart',
Handlebars = 'handlebars',

View File

@@ -27,6 +27,8 @@ import getLabelsColorMap, {
import { getAnalogousColors } from './utils';
import { FeatureFlag, isFeatureEnabled } from '../utils';
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
// Use type augmentation to correct the fact that
// an instance of CategoricalScale is also a function
interface CategoricalColorScale {

View File

@@ -228,13 +228,16 @@ const CustomModal = ({
};
let FooterComponent;
if (isValidElement(footer)) {
// This safely avoids injecting "closeModal" into native elements like <div> or <span>
if (isValidElement(footer) && typeof footer.type === 'function')
// If a footer component is provided inject a closeModal function
// so the footer can provide a "close" button if desired
FooterComponent = cloneElement(footer, {
closeModal: handleOnHide,
} as Partial<unknown>);
}
else FooterComponent = footer;
const modalFooter = isNil(FooterComponent)
? [
<Button
@@ -309,7 +312,7 @@ const CustomModal = ({
open={show}
title={<ModalTitle />}
closeIcon={
<span className="close" aria-hidden="true">
<span data-test="close-modal-btn" className="close" aria-hidden="true">
×
</span>
}

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { StoryObj } from '@storybook/react';
import { noop } from 'lodash';
import { SelectOptionsType, SelectProps } from './types';
import { Select } from '.';
@@ -93,23 +94,26 @@ export const InteractiveSelect: StoryObj = {
options,
optionsCount,
...args
}: SelectProps & { header: string; optionsCount: number }) => (
<div
style={{
width: DEFAULT_WIDTH,
}}
>
<Select
{...args}
options={
Array.isArray(options)
? generateOptions(options, optionsCount)
: options
}
mode="multiple"
/>
</div>
),
}: SelectProps & { header: string; optionsCount: number }) => {
noop(header);
return (
<div
style={{
width: DEFAULT_WIDTH,
}}
>
<Select
{...args}
options={
Array.isArray(options)
? generateOptions(options, optionsCount)
: options
}
mode="multiple"
/>
</div>
);
},
args: {
autoFocus: true,
allowNewOptions: false,

View File

@@ -0,0 +1,47 @@
/**
* 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 { ReactElement } from 'react';
import { UnsavedChangesModal, type UnsavedChangesModalProps } from '.';
export default {
title: 'Components/UnsavedChangesModal',
component: UnsavedChangesModal,
};
export const InteractiveUnsavedChangesModal = (
props: UnsavedChangesModalProps,
): ReactElement => (
<UnsavedChangesModal {...props}>
If you don't save, changes will be lost.
</UnsavedChangesModal>
);
InteractiveUnsavedChangesModal.args = {
showModal: true,
onHide: () => {},
handleSave: () => {},
onConfirmNavigation: () => {},
title: 'Unsaved Changes',
};
InteractiveUnsavedChangesModal.argTypes = {
onHide: { action: 'onHide' },
handleSave: { action: 'handleSave' },
onConfirmNavigation: { action: 'onConfirmNavigation' },
};

View File

@@ -0,0 +1,96 @@
/**
* 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 '@superset-ui/core/spec';
import { UnsavedChangesModal } from '.';
test('should render nothing if showModal is false', () => {
const { queryByRole } = render(
<UnsavedChangesModal
showModal={false}
onHide={() => {}}
handleSave={() => {}}
onConfirmNavigation={() => {}}
/>,
);
expect(queryByRole('dialog')).not.toBeInTheDocument();
});
test('should render the UnsavedChangesModal component if showModal is true', async () => {
const { queryByRole } = render(
<UnsavedChangesModal
showModal
onHide={() => {}}
handleSave={() => {}}
onConfirmNavigation={() => {}}
/>,
);
expect(queryByRole('dialog')).toBeInTheDocument();
});
test('should only call onConfirmNavigation when clicking the Discard button', async () => {
const mockOnHide = jest.fn();
const mockHandleSave = jest.fn();
const mockOnConfirmNavigation = jest.fn();
render(
<UnsavedChangesModal
showModal
onHide={mockOnHide}
handleSave={mockHandleSave}
onConfirmNavigation={mockOnConfirmNavigation}
/>,
);
const discardButton: HTMLElement = await screen.findByRole('button', {
name: /discard/i,
});
userEvent.click(discardButton);
expect(mockOnConfirmNavigation).toHaveBeenCalled();
expect(mockHandleSave).not.toHaveBeenCalled();
expect(mockOnHide).not.toHaveBeenCalled();
});
test('should only call handleSave when clicking the Save button', async () => {
const mockOnHide = jest.fn();
const mockHandleSave = jest.fn();
const mockOnConfirmNavigation = jest.fn();
render(
<UnsavedChangesModal
showModal
onHide={mockOnHide}
handleSave={mockHandleSave}
onConfirmNavigation={mockOnConfirmNavigation}
/>,
);
const saveButton: HTMLElement = await screen.findByRole('button', {
name: /save/i,
});
userEvent.click(saveButton);
expect(mockHandleSave).toHaveBeenCalled();
expect(mockOnHide).not.toHaveBeenCalled();
expect(mockOnConfirmNavigation).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,129 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t, styled, css } from '@superset-ui/core';
import { Icons, Modal, Typography } from '@superset-ui/core/components';
import { Button } from '@superset-ui/core/components/Button';
import type { FC, ReactElement } from 'react';
const StyledModalTitle = styled(Typography.Title)`
&& {
font-weight: 600;
margin: 0;
}
`;
const StyledModalBody = styled(Typography.Text)`
${({ theme }) => css`
padding: 0 ${theme.sizeUnit * 2}px;
&& {
margin: 0;
}
`}
`;
const StyledDiscardBtn = styled(Button)`
${({ theme }) => css`
min-width: ${theme.sizeUnit * 22}px;
height: ${theme.sizeUnit * 8}px;
`}
`;
const StyledSaveBtn = styled(Button)`
${({ theme }) => css`
min-width: ${theme.sizeUnit * 17}px;
height: ${theme.sizeUnit * 8}px;
span > :first-of-type {
margin-right: 0;
}
`}
`;
const StyledWarningIcon = styled(Icons.WarningOutlined)`
${({ theme }) => css`
color: ${theme.colors.warning.base};
margin-right: ${theme.sizeUnit * 4}px;
`}
`;
export type UnsavedChangesModalProps = {
showModal: boolean;
onHide: () => void;
handleSave: () => void;
onConfirmNavigation: () => void;
title?: string;
body?: string;
};
export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
showModal,
onHide,
handleSave,
onConfirmNavigation,
title = 'Unsaved Changes',
body = "If you don't save, changes will be lost.",
}: UnsavedChangesModalProps): ReactElement => (
<Modal
centered
responsive
onHide={onHide}
show={showModal}
width="444px"
title={
<div
css={css`
align-items: center;
display: flex;
`}
>
<StyledWarningIcon iconSize="xl" />
<StyledModalTitle type="secondary" level={5}>
{title}
</StyledModalTitle>
</div>
}
footer={
<div
css={css`
display: flex;
justify-content: flex-end;
width: 100%;
`}
>
<StyledDiscardBtn
htmlType="button"
buttonSize="small"
onClick={onConfirmNavigation}
>
{t('Discard')}
</StyledDiscardBtn>
<StyledSaveBtn
htmlType="button"
buttonSize="small"
buttonStyle="primary"
onClick={handleSave}
>
{t('Save')}
</StyledSaveBtn>
</div>
}
>
<StyledModalBody type="secondary">{body}</StyledModalBody>
</Modal>
);

View File

@@ -163,5 +163,6 @@ export * from './Steps';
export * from './Table';
export * from './TableView';
export * from './Tag';
export * from './UnsavedChangesModal';
export * from './constants';
export * from './Result';

View File

@@ -21,6 +21,8 @@ import { ExtensibleFunction } from '../models';
import { getNumberFormatter, NumberFormats } from '../number-format';
import { Currency } from '../query';
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
interface CurrencyFormatterConfig {
d3Format?: string;
currency: Currency;

View File

@@ -20,6 +20,8 @@ import { ExtensibleFunction } from '../models';
import { isRequired } from '../utils';
import { NumberFormatFunction } from './types';
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
export const PREVIEW_VALUE = 12345.432;
export interface NumberFormatterConfig {

View File

@@ -35,6 +35,7 @@ import {
CacheProvider as EmotionCacheProvider,
} from '@emotion/react';
import createCache from '@emotion/cache';
import { noop } from 'lodash';
import { GlobalStyles } from './GlobalStyles';
import {
@@ -285,6 +286,7 @@ export class Theme {
antdConfig: AntdThemeConfig,
emotionCache: any,
): void {
noop(theme, antdConfig, emotionCache);
// Overridden at runtime by SupersetThemeProvider using setThemeState
}

View File

@@ -21,6 +21,8 @@ import { isRequired } from '../utils';
import { TimeFormatFunction } from './types';
import stringifyTimeInput from './utils/stringifyTimeInput';
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
export const PREVIEW_TIME = new Date(Date.UTC(2017, 1, 14, 11, 22, 33));
// Use type augmentation to indicate that

View File

@@ -19,6 +19,8 @@
import { ExtensibleFunction } from '../models';
import { TimeRangeFormatFunction } from './types';
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
// Use type augmentation to indicate that
// an instance of TimeFormatter is also a function
interface TimeRangeFormatter {

View File

@@ -35,6 +35,13 @@ const xssFilter = new FilterXSS({
'width',
'muted',
],
table: ['width', 'border', 'align', 'valign', 'style'],
tr: ['rowspan', 'align', 'valign', 'style'],
td: ['width', 'rowspan', 'colspan', 'align', 'valign', 'style'],
th: ['width', 'rowspan', 'colspan', 'align', 'valign', 'style'],
tbody: ['align', 'valign', 'style'],
thead: ['align', 'valign', 'style'],
tfoot: ['align', 'valign', 'style'],
},
stripIgnoreTag: true,
css: false,

View File

@@ -19,18 +19,28 @@
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' &&
v.trim().length > 0 &&
v.trim().startsWith('mapbox://styles/')
) {
return false;
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;
}
}
return t('is expected to be a Mapbox URL');
return t(
'is expected to be a Mapbox/OSM URL (eg. mapbox://styles/...) or a tile server URL (eg. tile://http...)',
);
}

View File

@@ -17,6 +17,8 @@
* under the License.
*/
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
import { ExtensibleFunction } from '@superset-ui/core';
describe('ExtensibleFunction', () => {

View File

@@ -150,3 +150,32 @@ test('should sanitize HTML input', () => {
expect(html).toMatch(expectedHtml);
});
test('should preserve table styling after sanitization (fixes ECharts tooltip formatting)', () => {
const tableWithStyles = `
<table>
<tr style="opacity: 0.8;">
<td style="text-align: left; padding-left: 0px;">Label</td>
<td style="text-align: right; padding-left: 16px;">Value</td>
</tr>
</table>
`;
const sanitized = sanitizeHtml(tableWithStyles);
expect(sanitized).toContain('style="opacity: 0.8;"');
expect(sanitized).toContain('style="text-align: left; padding-left: 0px;"');
expect(sanitized).toContain('style="text-align: right; padding-left: 16px;"');
const data = [
['Metric', 'Value'],
['Sales', '$1,234'],
];
const html = tooltipHtml(data, 'Test Tooltip');
expect(html).toContain('style="opacity: 0.8;"');
expect(html).toContain('text-align: left');
expect(html).toContain('text-align: right');
expect(html).toContain('padding-left: 0px');
expect(html).toContain('padding-left: 16px');
expect(html).toContain('max-width: 300px');
});

View File

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

View File

@@ -33,7 +33,7 @@
"dependencies": {
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@react-icons/all-files": "^4.1.0",
"@storybook/addon-actions": "8.1.11",

View File

@@ -28,9 +28,7 @@ const colorTypes = [
'grayscale',
];
const AntDFunctionalColors = ({ antdTheme }) => {
const { antd } = supersetTheme;
const AntDFunctionalColors = () => {
// Define color types and variants dynamically
const variants = [
'active',
@@ -44,7 +42,6 @@ const AntDFunctionalColors = ({ antdTheme }) => {
'bg',
];
const { colors } = supersetTheme;
return (
<table
style={{ borderCollapse: 'collapse', width: '100%', textAlign: 'left' }}
@@ -63,32 +60,29 @@ const AntDFunctionalColors = ({ antdTheme }) => {
</tr>
</thead>
<tbody>
{colorTypes.map(type => {
const typeKey = `color${type}`;
return (
<tr key={type}>
<td style={{ border: '1px solid #ddd', padding: '8px' }}>
<strong>{type}</strong>
</td>
{variants.map(variant => {
const color = themeObject.getColorVariants(type)[variant];
return (
<td
key={variant}
style={{
border: '1px solid #ddd',
padding: '8px',
backgroundColor: color || 'transparent',
color: [`color${type}${variant}`],
}}
>
{color ? <code>{color}</code> : '-'}
</td>
);
})}
</tr>
);
})}
{colorTypes.map(type => (
<tr key={type}>
<td style={{ border: '1px solid #ddd', padding: '8px' }}>
<strong>{type}</strong>
</td>
{variants.map(variant => {
const color = themeObject.getColorVariants(type)[variant];
return (
<td
key={variant}
style={{
border: '1px solid #ddd',
padding: '8px',
backgroundColor: color || 'transparent',
color: `color${type}${variant}`,
}}
>
{color ? <code>{color}</code> : '-'}
</td>
);
})}
</tr>
))}
</tbody>
</table>
);
@@ -159,7 +153,7 @@ export const ThemeColors = () => {
</table>
<h2>Ant Design Theme Colors</h2>
<h3>Functional Colors</h3>
<AntDFunctionalColors antdTheme={supersetTheme.antd} />
<AntDFunctionalColors />
<h2>The supersetTheme object</h2>
<pre>
<code>{JSON.stringify(supersetTheme, null, 2)}</code>

View File

@@ -24,10 +24,11 @@
"lib"
],
"dependencies": {
"@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",
"@deck.gl/aggregation-layers": "^9.1.9",
"@deck.gl/core": "^9.1.9",
"@deck.gl/geo-layers": "^9.1.9",
"@deck.gl/layers": "^9.1.9",
"@deck.gl/react": "^9.1.9",
"@mapbox/geojson-extent": "^1.0.1",
"@math.gl/web-mercator": "^4.1.0",
"@types/d3-array": "^2.0.0",

View File

@@ -37,6 +37,12 @@ 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
@@ -92,6 +98,20 @@ 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 =>
@@ -100,7 +120,7 @@ export const DeckGLContainer = memo(
}
return props.layers as Layer[];
}, [props.layers]);
}, [props.layers, props.mapStyle]);
const { children = null, height, width } = props;
@@ -115,11 +135,13 @@ export const DeckGLContainer = memo(
viewState={viewState}
onViewStateChange={onViewStateChange}
>
<StaticMap
preserveDrawingBuffer
mapStyle={props.mapStyle || 'light'}
mapboxApiAccessToken={props.mapboxApiAccessToken}
/>
{props.mapStyle?.startsWith(MAPBOX_LAYER_PREFIX) && (
<StaticMap
preserveDrawingBuffer
mapStyle={props.mapStyle || 'light'}
mapboxApiAccessToken={props.mapboxApiAccessToken}
/>
)}
</DeckGL>
{children}
</div>

View File

@@ -29,6 +29,30 @@ import {
import { D3_FORMAT_OPTIONS, sharedControls } from '@superset-ui/chart-controls';
import { columnChoices, PRIMARY_COLOR } from './controls';
let deckglTiles;
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;
};
const DEFAULT_VIEWPORT = {
longitude: 6.85236157047845,
latitude: 31.222656842808707,
@@ -372,17 +396,10 @@ export const mapboxStyle = {
renderTrigger: true,
freeForm: true,
validators: [validateMapboxStylesUrl],
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',
choices: getDeckGLTiles(),
default: getDeckGLTiles()[0][0],
description: t(
'Base layer map style. See Mapbox documentation: %s',
'Mapbox base layer map style (see Mapbox documentation: %s) or tile server URL.',
'https://docs.mapbox.com/help/glossary/style-url/',
),
},

View File

@@ -25,10 +25,16 @@ import {
QueryFormData,
SequentialScheme,
} from '@superset-ui/core';
import { GeoBoundingBox, TileLayer } from '@deck.gl/geo-layers';
import { BitmapLayer, PathLayer } from '@deck.gl/layers';
import { hexToRGB } from './utils/colors';
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;
@@ -190,3 +196,42 @@ 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)[];
}
interface RenderSubLayerProps {
tile: {
bbox: GeoBoundingBox;
};
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);
}

View File

@@ -23,6 +23,7 @@ import {
sharedControls,
sections,
} from '@superset-ui/chart-controls';
import { noop } from 'lodash';
import {
headerFontSize,
subheaderFontSize,
@@ -143,6 +144,7 @@ const config: ControlPanelConfig = {
return true;
},
mapStateToProps(explore, _, chart) {
noop(explore, _, chart);
return {
columnsPropsObject: {
colnames: ['Previous value', 'Delta', 'Percent change'],

View File

@@ -36,7 +36,10 @@ jest.mock('@superset-ui/core', () => ({
jest.mock('../utils', () => ({
getDateFormatter: jest.fn(() => (v: any) => `${v}pm`),
parseMetricValue: jest.fn(val => Number(val)),
getOriginalLabel: jest.fn((metric, metrics) => metric),
getOriginalLabel: jest.fn((metric, metrics) => {
console.log(metrics);
return metric;
}),
}));
describe('BigNumberTotal transformProps', () => {

View File

@@ -39,7 +39,10 @@ jest.mock('@superset-ui/core', () => ({
jest.mock('../utils', () => ({
getDateFormatter: jest.fn(() => (v: any) => `${v}pm`),
parseMetricValue: jest.fn(val => Number(val)),
getOriginalLabel: jest.fn((metric, metrics) => metric),
getOriginalLabel: jest.fn((metric, metrics) => {
console.log(metrics);
return metric;
}),
}));
jest.mock('../../utils/tooltip', () => ({

View File

@@ -0,0 +1,89 @@
/**
* 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 { useEffect, useRef, useState } from 'react';
import { sharedControlComponents } from '@superset-ui/chart-controls';
import { t } from '@superset-ui/core';
import Echart from '../components/Echart';
import { EchartsGanttChartTransformedProps } from './types';
import { EventHandlers } from '../types';
const { RadioButtonControl } = sharedControlComponents;
export default function EchartsGantt(props: EchartsGanttChartTransformedProps) {
const {
height,
width,
echartOptions,
selectedValues,
refs,
formData,
setControlValue,
onLegendStateChanged,
} = props;
const extraControlRef = useRef<HTMLDivElement>(null);
const [extraHeight, setExtraHeight] = useState(0);
useEffect(() => {
const updatedHeight = extraControlRef.current?.offsetHeight ?? 0;
setExtraHeight(updatedHeight);
}, [formData.showExtraControls]);
const eventHandlers: EventHandlers = {
legendselectchanged: payload => {
requestAnimationFrame(() => {
onLegendStateChanged?.(payload.selected);
});
},
legendselectall: payload => {
requestAnimationFrame(() => {
onLegendStateChanged?.(payload.selected);
});
},
legendinverseselect: payload => {
requestAnimationFrame(() => {
onLegendStateChanged?.(payload.selected);
});
},
};
return (
<>
<div ref={extraControlRef} css={{ textAlign: 'center' }}>
{formData.showExtraControls ? (
<RadioButtonControl
options={[
[false, t('Plain')],
[true, t('Subcategories')],
]}
value={formData.subcategories}
onChange={v => setControlValue?.('subcategories', v)}
/>
) : null}
</div>
<Echart
refs={refs}
height={height - extraHeight}
width={width}
echartOptions={echartOptions}
selectedValues={selectedValues}
eventHandlers={eventHandlers}
/>
</>
);
}

View File

@@ -0,0 +1,61 @@
/**
* 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 {
QueryFormData,
QueryObject,
buildQueryContext,
ensureIsArray,
} from '@superset-ui/core';
export default function buildQuery(formData: QueryFormData) {
const {
start_time,
end_time,
y_axis,
series,
tooltip_columns,
tooltip_metrics,
order_by_cols,
} = formData;
const groupBy = ensureIsArray(series);
const orderby = ensureIsArray(order_by_cols).map(
expr => JSON.parse(expr) as [string, boolean],
);
const columns = Array.from(
new Set([
start_time,
end_time,
y_axis,
...groupBy,
...ensureIsArray(tooltip_columns),
...orderby.map(v => v[0]),
]),
);
return buildQueryContext(formData, (baseQueryObject: QueryObject) => [
{
...baseQueryObject,
columns,
metrics: ensureIsArray(tooltip_metrics),
orderby,
series_columns: groupBy,
},
]);
}

View File

@@ -16,16 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
export const ELEMENT_HEIGHT_SCALE = 0.85 as const;
// /superset/sqllab_viz
interface SqlLabPostRequest {
data: {
schema: string;
sql: string;
dbId: number;
templateParams?: string | undefined;
datasourceName: string;
metrics?: string[];
columns?: string[];
};
export enum Dimension {
StartTime = 'startTime',
EndTime = 'endTime',
Index = 'index',
SeriesCount = 'seriesCount',
}

View File

@@ -0,0 +1,136 @@
/**
* 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 {
ControlPanelConfig,
ControlSubSectionHeader,
sections,
sharedControls,
} from '@superset-ui/chart-controls';
import { GenericDataType, t } from '@superset-ui/core';
import {
legendSection,
showExtraControls,
tooltipTimeFormatControl,
tooltipValuesFormatControl,
} from '../controls';
const config: ControlPanelConfig = {
controlPanelSections: [
{
label: t('Query'),
expanded: true,
controlSetRows: [
[
{
name: 'start_time',
config: {
...sharedControls.entity,
label: t('Start Time'),
description: undefined,
allowedDataTypes: [GenericDataType.Temporal],
},
},
],
[
{
name: 'end_time',
config: {
...sharedControls.entity,
label: t('End Time'),
description: undefined,
allowedDataTypes: [GenericDataType.Temporal],
},
},
],
[
{
name: 'y_axis',
config: {
...sharedControls.x_axis,
label: t('Y-axis'),
description: t('Dimension to use on y-axis.'),
initialValue: () => undefined,
},
},
],
['series'],
[
{
name: 'subcategories',
config: {
type: 'CheckboxControl',
label: t('Subcategories'),
description: t(
'Divides each category into subcategories based on the values in ' +
'the dimension. It can be used to exclude intersections.',
),
renderTrigger: true,
default: false,
visibility: ({ controls }) => !!controls?.series?.value,
},
},
],
['tooltip_metrics'],
['tooltip_columns'],
['adhoc_filters'],
['order_by_cols'],
['row_limit'],
],
},
{
...sections.titleControls,
controlSetRows: [...sections.titleControls.controlSetRows.slice(0, -1)],
},
{
label: t('Chart Options'),
expanded: true,
tabOverride: 'customize',
controlSetRows: [
['color_scheme'],
...legendSection,
['zoomable'],
[showExtraControls],
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
[
{
name: 'x_axis_time_bounds',
config: {
type: 'TimeRangeControl',
label: t('Bounds'),
description: t(
'Bounds for the X-axis. Selected time merges with ' +
'min/max date of the data. When left empty, bounds ' +
'dynamically defined based on the min/max of the data.',
),
renderTrigger: true,
allowClear: true,
allowEmpty: [true, true],
},
},
],
['x_axis_time_format'],
[<ControlSubSectionHeader>{t('Tooltip')}</ControlSubSectionHeader>],
[tooltipTimeFormatControl],
[tooltipValuesFormatControl],
],
},
],
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -0,0 +1,54 @@
/**
* 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 { Behavior, t } from '@superset-ui/core';
import transformProps from './transformProps';
import controlPanel from './controlPanel';
import buildQuery from './buildQuery';
import { EchartsChartPlugin } from '../types';
import thumbnail from './images/thumbnail.png';
import example1 from './images/example1.png';
import example2 from './images/example2.png';
export default class EchartsGanttChartPlugin extends EchartsChartPlugin {
constructor() {
super({
buildQuery,
controlPanel,
loadChart: () => import('./EchartsGantt'),
metadata: {
behaviors: [
Behavior.InteractiveChart,
Behavior.DrillToDetail,
Behavior.DrillBy,
],
credits: ['https://echarts.apache.org'],
name: t('Gantt Chart'),
description: t(
'Gantt chart visualizes important events over a time span. ' +
'Every data point displayed as a separate event along a ' +
'horizontal line.',
),
tags: [t('ECharts'), t('Featured'), t('Timeline'), t('Time')],
thumbnail,
exampleGallery: [{ url: example1 }, { url: example2 }],
},
transformProps,
});
}
}

View File

@@ -0,0 +1,440 @@
/**
* 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 {
CustomSeriesOption,
CustomSeriesRenderItem,
EChartsCoreOption,
LineSeriesOption,
} from 'echarts';
import {
AxisType,
CategoricalColorNamespace,
DataRecord,
DataRecordValue,
GenericDataType,
getColumnLabel,
getNumberFormatter,
t,
tooltipHtml,
} from '@superset-ui/core';
import { CallbackDataParams } from 'echarts/types/src/util/types';
import dayjs from 'dayjs';
import {
Cartesian2dCoordSys,
EchartsGanttChartProps,
EchartsGanttFormData,
} from './types';
import { DEFAULT_FORM_DATA, TIMESERIES_CONSTANTS } from '../constants';
import { Refs } from '../types';
import { getLegendProps, groupData } from '../utils/series';
import {
getTooltipTimeFormatter,
getXAxisFormatter,
} from '../utils/formatters';
import { defaultGrid } from '../defaults';
import { getPadding } from '../Timeseries/transformers';
import { convertInteger } from '../utils/convertInteger';
import { getTooltipLabels } from '../utils/tooltip';
import { Dimension, ELEMENT_HEIGHT_SCALE } from './constants';
const renderItem: CustomSeriesRenderItem = (params, api) => {
const startX = api.value(Dimension.StartTime);
const endX = api.value(Dimension.EndTime);
const index = Number(api.value(Dimension.Index));
const seriesCount = Number(api.value(Dimension.SeriesCount));
if (Number.isNaN(index)) {
return null;
}
const startY = seriesCount - 1 - index;
const endY = startY - 1;
const startCoord = api.coord([startX, startY]);
const endCoord = api.coord([endX, endY]);
const baseHeight = endCoord[1] - startCoord[1];
const height = baseHeight * ELEMENT_HEIGHT_SCALE;
const coordSys = params.coordSys as Cartesian2dCoordSys;
const bounds = [coordSys.x, coordSys.x + coordSys.width];
// left bound
startCoord[0] = Math.max(startCoord[0], bounds[0]);
endCoord[0] = Math.max(startCoord[0], endCoord[0]);
// right bound
startCoord[0] = Math.min(startCoord[0], bounds[1]);
endCoord[0] = Math.min(endCoord[0], bounds[1]);
const width = endCoord[0] - startCoord[0];
if (width <= 0 || height <= 0) {
return null;
}
return {
type: 'rect',
transition: ['shape'],
shape: {
x: startCoord[0],
y: startCoord[1] - height - (baseHeight - height) / 2,
width,
height,
},
style: api.style(),
};
};
export default function transformProps(chartProps: EchartsGanttChartProps) {
const {
formData,
queriesData,
height,
hooks,
filterState,
width,
theme,
emitCrossFilters,
datasource,
legendState,
} = chartProps;
const {
startTime,
endTime,
yAxis,
series: dimension,
tooltipMetrics,
tooltipColumns,
xAxisTimeFormat,
tooltipTimeFormat,
tooltipValuesFormat,
colorScheme,
sliceId,
zoomable,
legendMargin,
legendOrientation,
legendType,
showLegend,
yAxisTitle,
yAxisTitleMargin,
xAxisTitle,
xAxisTitleMargin,
xAxisTimeBounds,
subcategories,
}: EchartsGanttFormData = {
...DEFAULT_FORM_DATA,
...formData,
};
const { setControlValue, onLegendStateChanged } = hooks;
const { data = [], colnames = [], coltypes = [] } = queriesData[0];
const refs: Refs = {};
const startTimeLabel = getColumnLabel(startTime);
const endTimeLabel = getColumnLabel(endTime);
const yAxisLabel = getColumnLabel(yAxis);
const dimensionLabel = dimension ? getColumnLabel(dimension) : undefined;
const tooltipLabels = getTooltipLabels({ tooltipMetrics, tooltipColumns });
const seriesMap = groupData(data, dimensionLabel);
const seriesInCategoriesMap = new Map<
DataRecordValue | undefined,
Map<DataRecordValue | undefined, number>
>();
data.forEach(datum => {
const category = datum[yAxisLabel];
let dimensionValue: DataRecordValue | undefined;
if (dimensionLabel) {
if (legendState && !legendState[String(datum[dimensionLabel])]) {
return;
}
if (subcategories) {
dimensionValue = datum[dimensionLabel];
}
}
const seriesMap = seriesInCategoriesMap.get(category);
if (seriesMap) {
const dimensionMapValue = seriesMap.get(dimensionValue);
if (dimensionMapValue === undefined) {
seriesMap.set(dimensionValue, seriesMap.size);
}
} else {
seriesInCategoriesMap.set(category, new Map([[dimensionValue, 0]]));
}
});
let seriesCount = 0;
const categoryAndSeriesToIndexMap: typeof seriesInCategoriesMap = new Map();
Array.from(seriesInCategoriesMap.entries()).forEach(([key, map]) => {
categoryAndSeriesToIndexMap.set(
key,
new Map(
Array.from(map.entries()).map(([key2, idx]) => [
key2,
seriesCount + idx,
]),
),
);
seriesCount += map.size;
});
const borderLines: { yAxis: number }[] = [];
const categoryLines: { yAxis: number; name?: string }[] = [];
let sum = 0;
let prevSum = 0;
Array.from(seriesInCategoriesMap.entries()).forEach(([key, map]) => {
sum += map.size;
categoryLines.push({
yAxis: seriesCount - (sum + prevSum) / 2,
name: key ? String(key) : undefined,
});
borderLines.push({ yAxis: seriesCount - sum });
prevSum = sum;
});
const xAxisFormatter = getXAxisFormatter(xAxisTimeFormat);
const tooltipTimeFormatter = getTooltipTimeFormatter(tooltipTimeFormat);
const tooltipValuesFormatter = getNumberFormatter(tooltipValuesFormat);
const bounds: [number | undefined, number | undefined] = [
undefined,
undefined,
];
if (xAxisTimeBounds?.[0]) {
const minDate = Math.min(
...data.map(datum => Number(datum[startTimeLabel] ?? 0)),
);
const time = dayjs(xAxisTimeBounds[0], 'HH:mm:ss');
bounds[0] = +dayjs
.utc(minDate)
.hour(time.hour())
.minute(time.minute())
.second(time.second());
}
if (xAxisTimeBounds?.[1]) {
const maxDate = Math.min(
...data.map(datum => Number(datum[endTimeLabel] ?? 0)),
);
const time = dayjs(xAxisTimeBounds[1], 'HH:mm:ss');
bounds[1] = +dayjs
.utc(maxDate)
.hour(time.hour())
.minute(time.minute())
.second(time.second());
}
const padding = getPadding(
showLegend && seriesMap.size > 1,
legendOrientation,
false,
zoomable,
legendMargin,
!!xAxisTitle,
'Left',
convertInteger(yAxisTitleMargin),
convertInteger(xAxisTitleMargin),
);
const colorScale = CategoricalColorNamespace.getScale(colorScheme as string);
const getIndex = (datum: DataRecord) => {
const seriesMap = categoryAndSeriesToIndexMap.get(datum[yAxisLabel]);
const series =
subcategories && dimensionLabel ? datum[dimensionLabel] : undefined;
return seriesMap ? seriesMap.get(series) : undefined;
};
const series: (CustomSeriesOption | LineSeriesOption)[] = Array.from(
seriesMap.entries(),
)
.map(([key, data], idx) => ({
name: key as string | undefined,
// For some reason items can visually disappear if progressive enabled.
progressive: 0,
itemStyle: {
color: colorScale(String(key), sliceId ?? idx),
},
type: 'custom' as const,
renderItem,
data: data.map(datum => ({
value: [
datum[startTimeLabel],
datum[endTimeLabel],
getIndex(datum),
seriesCount,
...Object.values(datum),
],
})),
dimensions: [...Object.values(Dimension), ...colnames],
encode: {
x: [0, 1],
},
}))
.sort((a, b) => String(a.name).localeCompare(String(b.name)));
series.push(
{
animation: false,
type: 'line' as const,
markLine: {
silent: true,
symbol: ['none', 'none'],
lineStyle: {
type: 'dashed',
// eslint-disable-next-line theme-colors/no-literal-colors
color: '#dbe0ea',
},
label: {
show: false,
},
data: borderLines,
},
},
{
animation: false,
type: 'line',
markLine: {
silent: true,
symbol: ['none', 'none'],
lineStyle: {
type: 'solid',
// eslint-disable-next-line theme-colors/no-literal-colors
color: '#00000000',
},
label: {
show: true,
position: 'start',
formatter: '{b}',
},
data: categoryLines,
},
},
);
const tooltipFormatterMap = {
[GenericDataType.Numeric]: tooltipValuesFormatter,
[GenericDataType.String]: undefined,
[GenericDataType.Temporal]: tooltipTimeFormatter,
[GenericDataType.Boolean]: undefined,
};
const echartOptions: EChartsCoreOption = {
useUTC: true,
tooltip: {
formatter: (params: CallbackDataParams) =>
tooltipHtml(
tooltipLabels.map(label => {
const offset = Object.keys(Dimension).length;
const dimensionNames = params.dimensionNames!.slice(offset);
const data = (params.value as any[]).slice(offset);
const idx = dimensionNames.findIndex(v => v === label)!;
const value = data[idx];
const type = coltypes[idx];
return [label, tooltipFormatterMap[type]?.(value) ?? value];
}),
dimensionLabel ? params.seriesName : undefined,
),
},
legend: {
...getLegendProps(
legendType,
legendOrientation,
showLegend,
theme,
zoomable,
legendState,
),
},
grid: {
...defaultGrid,
...padding,
},
dataZoom: zoomable && [
{
type: 'slider',
filterMode: 'none',
start: TIMESERIES_CONSTANTS.dataZoomStart,
end: TIMESERIES_CONSTANTS.dataZoomEnd,
bottom: TIMESERIES_CONSTANTS.zoomBottom,
},
],
toolbox: {
show: zoomable,
top: TIMESERIES_CONSTANTS.toolboxTop,
right: TIMESERIES_CONSTANTS.toolboxRight,
feature: {
dataZoom: {
yAxisIndex: false,
title: {
zoom: t('zoom area'),
back: t('restore zoom'),
},
},
},
},
series,
xAxis: {
name: xAxisTitle,
nameLocation: 'middle',
type: AxisType.Time,
nameGap: convertInteger(xAxisTitleMargin),
axisLabel: {
formatter: xAxisFormatter,
hideOverlap: true,
},
min: bounds[0],
max: bounds[1],
},
yAxis: {
name: yAxisTitle,
nameGap: convertInteger(yAxisTitleMargin),
nameLocation: 'middle',
axisLabel: {
show: false,
},
splitLine: {
show: false,
},
type: AxisType.Value,
min: 0,
max: seriesCount,
},
};
return {
formData,
queriesData,
echartOptions,
height,
filterState,
width,
theme,
hooks,
emitCrossFilters,
datasource,
refs,
setControlValue,
onLegendStateChanged,
};
}

View File

@@ -0,0 +1,71 @@
/**
* 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 {
ChartDataResponseResult,
ChartProps,
QueryFormColumn,
QueryFormData,
QueryFormMetric,
} from '@superset-ui/core';
import {
BaseTransformedProps,
CrossFilterTransformedProps,
LegendFormData,
} from '../types';
export type EchartsGanttChartTransformedProps =
BaseTransformedProps<EchartsGanttFormData> & CrossFilterTransformedProps;
export type EchartsGanttFormData = QueryFormData &
LegendFormData & {
viz_type: 'gantt_chart';
startTime: QueryFormColumn;
endTime: QueryFormColumn;
yAxis: QueryFormColumn;
tooltipMetrics: QueryFormMetric[];
tooltipColumns: QueryFormColumn[];
series?: QueryFormColumn;
xAxisTimeFormat?: string;
tooltipTimeFormat?: string;
tooltipValuesFormat?: string;
colorScheme?: string;
zoomable?: boolean;
xAxisTitle?: string;
xAxisTitleMargin?: number;
yAxisTitle?: string;
yAxisTitleMargin?: number;
yAxisTitlePosition?: string;
xAxisTimeBounds?: [string | null, string | null];
subcategories?: boolean;
showExtraControls?: boolean;
};
export interface EchartsGanttChartProps
extends ChartProps<EchartsGanttFormData> {
formData: EchartsGanttFormData;
queriesData: ChartDataResponseResult[];
}
export interface Cartesian2dCoordSys {
type: 'cartesian2d';
x: number;
y: number;
width: number;
height: number;
}

View File

@@ -122,7 +122,6 @@ const config: ControlPanelConfig = {
label: t('X Axis Title'),
renderTrigger: true,
default: '',
description: t('Changing this control takes effect instantly'),
},
},
],
@@ -148,7 +147,6 @@ const config: ControlPanelConfig = {
label: t('Y Axis Title'),
renderTrigger: true,
default: '',
description: t('Changing this control takes effect instantly'),
},
},
],

View File

@@ -58,7 +58,6 @@ const {
stack,
truncateYAxis,
yAxisBounds,
zoomable,
yAxisIndex,
} = DEFAULT_FORM_DATA;
@@ -352,18 +351,7 @@ const config: ControlPanelConfig = {
['time_shift_color'],
...createCustomizeSection(t('Query A'), ''),
...createCustomizeSection(t('Query B'), 'B'),
[
{
name: 'zoomable',
config: {
type: 'CheckboxControl',
label: t('Data Zoom'),
default: zoomable,
renderTrigger: true,
description: t('Enable data zooming controls'),
},
},
],
['zoomable'],
[minorTicks],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],

View File

@@ -54,7 +54,6 @@ const {
seriesType,
truncateYAxis,
yAxisBounds,
zoomable,
} = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -171,18 +170,7 @@ const config: ControlPanelConfig = {
},
],
[minorTicks],
[
{
name: 'zoomable',
config: {
type: 'CheckboxControl',
label: t('Data Zoom'),
default: zoomable,
renderTrigger: true,
description: t('Enable data zooming controls'),
},
},
],
['zoomable'],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
[

View File

@@ -48,14 +48,8 @@ import {
} from '../../constants';
import { StackControlsValue } from '../../../constants';
const {
logAxis,
minorSplitLine,
truncateYAxis,
yAxisBounds,
zoomable,
orientation,
} = DEFAULT_FORM_DATA;
const { logAxis, minorSplitLine, truncateYAxis, yAxisBounds, orientation } =
DEFAULT_FORM_DATA;
function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
const isXAxis = axis === 'x';
@@ -72,7 +66,6 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
label: t('Axis Title'),
renderTrigger: true,
default: '',
description: t('Changing this control takes effect instantly'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
isXAxis ? isVertical(controls) : isHorizontal(controls),
disableStash: true,
@@ -91,7 +84,6 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
renderTrigger: true,
default: sections.TITLE_MARGIN_OPTIONS[0],
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
description: t('Changing this control takes effect instantly'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
isXAxis ? isVertical(controls) : isHorizontal(controls),
disableStash: true,
@@ -107,7 +99,6 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
label: t('Axis Title'),
renderTrigger: true,
default: '',
description: t('Changing this control takes effect instantly'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
isXAxis ? isHorizontal(controls) : isVertical(controls),
disableStash: true,
@@ -126,7 +117,6 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
renderTrigger: true,
default: sections.TITLE_MARGIN_OPTIONS[1],
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
description: t('Changing this control takes effect instantly'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
isXAxis ? isHorizontal(controls) : isVertical(controls),
disableStash: true,
@@ -145,7 +135,6 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
renderTrigger: true,
default: sections.TITLE_POSITION_OPTIONS[0][0],
choices: sections.TITLE_POSITION_OPTIONS,
description: t('Changing this control takes effect instantly'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
isXAxis ? isHorizontal(controls) : isVertical(controls),
disableStash: true,
@@ -368,18 +357,7 @@ const config: ControlPanelConfig = {
},
],
[minorTicks],
[
{
name: 'zoomable',
config: {
type: 'CheckboxControl',
label: t('Data Zoom'),
default: zoomable,
renderTrigger: true,
description: t('Enable data zooming controls'),
},
},
],
['zoomable'],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
...createAxisControl('x'),

View File

@@ -55,7 +55,6 @@ const {
seriesType,
truncateYAxis,
yAxisBounds,
zoomable,
} = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -158,18 +157,7 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'zoomable',
config: {
type: 'CheckboxControl',
label: t('Data Zoom'),
default: zoomable,
renderTrigger: true,
description: t('Enable data zooming controls'),
},
},
],
['zoomable'],
[minorTicks],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],

View File

@@ -51,7 +51,6 @@ const {
rowLimit,
truncateYAxis,
yAxisBounds,
zoomable,
} = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -100,18 +99,7 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'zoomable',
config: {
type: 'CheckboxControl',
label: t('Data Zoom'),
default: zoomable,
renderTrigger: true,
description: t('Enable data zooming controls'),
},
},
],
['zoomable'],
[minorTicks],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],

View File

@@ -51,7 +51,6 @@ const {
rowLimit,
truncateYAxis,
yAxisBounds,
zoomable,
} = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -100,18 +99,7 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'zoomable',
config: {
type: 'CheckboxControl',
label: t('Data Zoom'),
default: zoomable,
renderTrigger: true,
description: t('Enable data zooming controls'),
},
},
],
['zoomable'],
[minorTicks],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],

View File

@@ -51,7 +51,6 @@ const {
rowLimit,
truncateYAxis,
yAxisBounds,
zoomable,
} = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -152,18 +151,7 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'zoomable',
config: {
type: 'CheckboxControl',
label: t('Data Zoom'),
default: zoomable,
renderTrigger: true,
description: t('Enable data zooming controls'),
},
},
],
['zoomable'],
[minorTicks],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],

View File

@@ -48,10 +48,12 @@ import {
TreemapChart,
HeatmapChart,
SunburstChart,
CustomChart,
} from 'echarts/charts';
import { CanvasRenderer } from 'echarts/renderers';
import {
TooltipComponent,
TitleComponent,
GridComponent,
VisualMapComponent,
LegendComponent,
@@ -83,6 +85,7 @@ use([
CanvasRenderer,
BarChart,
BoxplotChart,
CustomChart,
FunnelChart,
GaugeChart,
GraphChart,
@@ -104,6 +107,7 @@ use([
LegendComponent,
ToolboxComponent,
TooltipComponent,
TitleComponent,
VisualMapComponent,
LabelLayout,
]);

View File

@@ -22,6 +22,7 @@ import {
ControlSetItem,
ControlSetRow,
ControlSubSectionHeader,
CustomControlItem,
DEFAULT_SORT_SERIES_DATA,
SORT_SERIES_CHOICES,
sharedControls,
@@ -185,7 +186,7 @@ const richTooltipControl: ControlSetItem = {
},
};
const tooltipTimeFormatControl: ControlSetItem = {
export const tooltipTimeFormatControl: ControlSetItem = {
name: 'tooltipTimeFormat',
config: {
...sharedControls.x_axis_time_format,
@@ -195,6 +196,15 @@ const tooltipTimeFormatControl: ControlSetItem = {
},
};
export const tooltipValuesFormatControl: CustomControlItem = {
name: 'tooltipValuesFormat',
config: {
...sharedControls.y_axis_format,
label: t('Number format'),
clearable: false,
},
};
const tooltipSortByMetricControl: ControlSetItem = {
name: 'tooltipSortByMetric',
config: {
@@ -367,3 +377,13 @@ export const forceCategorical: ControlSetItem = {
description: t('Make the x-axis categorical'),
},
};
export const showExtraControls: CustomControlItem = {
name: 'show_extra_controls',
config: {
type: 'CheckboxControl',
label: t('Extra Controls'),
renderTrigger: true,
default: false,
},
};

View File

@@ -43,6 +43,7 @@ export { default as EchartsSunburstChartPlugin } from './Sunburst';
export { default as EchartsBubbleChartPlugin } from './Bubble';
export { default as EchartsSankeyChartPlugin } from './Sankey';
export { default as EchartsWaterfallChartPlugin } from './Waterfall';
export { default as EchartsGanttChartPlugin } from './Gantt';
export { default as BoxPlotTransformProps } from './BoxPlot/transformProps';
export { default as FunnelTransformProps } from './Funnel/transformProps';
@@ -60,6 +61,7 @@ export { default as BubbleTransformProps } from './Bubble/transformProps';
export { default as WaterfallTransformProps } from './Waterfall/transformProps';
export { default as HistogramTransformProps } from './Histogram/transformProps';
export { default as SankeyTransformProps } from './Sankey/transformProps';
export { default as GanttTransformProps } from './Gantt/transformProps';
export { DEFAULT_FORM_DATA as TimeseriesDefaultFormData } from './Timeseries/constants';

View File

@@ -685,3 +685,20 @@ export function extractTooltipKeys(
}
return [forecastValue[0][TOOLTIP_SERIES_KEY]];
}
export function groupData(data: DataRecord[], by?: string | null) {
const seriesMap: Map<DataRecordValue | undefined, DataRecord[]> = new Map();
if (by) {
data.forEach(datum => {
const value = seriesMap.get(datum[by]);
if (value) {
value.push(datum);
} else {
seriesMap.set(datum[by], [datum]);
}
});
} else {
seriesMap.set(undefined, data);
}
return seriesMap;
}

View File

@@ -18,6 +18,12 @@
*/
import type { CallbackDataParams } from 'echarts/types/src/util/types';
import {
QueryFormColumn,
QueryFormMetric,
getColumnLabel,
getMetricLabel,
} from '@superset-ui/core';
import { TOOLTIP_OVERFLOW_MARGIN, TOOLTIP_POINTER_MARGIN } from '../constants';
import { Refs } from '../types';
@@ -80,3 +86,16 @@ export function getDefaultTooltip(refs: Refs) {
},
};
}
export function getTooltipLabels({
tooltipMetrics,
tooltipColumns,
}: {
tooltipMetrics?: QueryFormMetric[];
tooltipColumns?: QueryFormColumn[];
}) {
return [
...(tooltipMetrics ?? []).map(v => getMetricLabel(v)),
...(tooltipColumns ?? []).map(v => getColumnLabel(v)),
];
}

View File

@@ -0,0 +1,64 @@
/**
* 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 { QueryFormData } from '@superset-ui/core';
import buildQuery from '../../src/Gantt/buildQuery';
describe('Gantt buildQuery', () => {
const formData: QueryFormData = {
datasource: '1__table',
viz_type: 'gantt_chart',
start_time: 'start_time',
end_time: 'end_time',
y_axis: {
label: 'Y Axis',
sqlExpression: 'SELECT 1',
expressionType: 'SQL',
},
series: 'series',
tooltip_metrics: ['tooltip_metric'],
tooltip_columns: ['tooltip_column'],
order_by_cols: [
JSON.stringify(['start_time', true]),
JSON.stringify(['order_col', false]),
],
};
it('should build query', () => {
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toStrictEqual(['tooltip_metric']);
expect(query.columns).toStrictEqual([
'start_time',
'end_time',
{
label: 'Y Axis',
sqlExpression: 'SELECT 1',
expressionType: 'SQL',
},
'series',
'tooltip_column',
'order_col',
]);
expect(query.series_columns).toStrictEqual(['series']);
expect(query.orderby).toStrictEqual([
['start_time', true],
['order_col', false],
]);
});
});

View File

@@ -0,0 +1,269 @@
/**
* 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 { AxisType, ChartProps, supersetTheme } from '@superset-ui/core';
import {
LegendOrientation,
LegendType,
} from '@superset-ui/plugin-chart-echarts';
import transformProps from '../../src/Gantt/transformProps';
import {
EchartsGanttChartProps,
EchartsGanttFormData,
} from '../../src/Gantt/types';
describe('Gantt transformProps', () => {
const formData: EchartsGanttFormData = {
viz_type: 'gantt_chart',
datasource: '1__table',
startTime: 'startTime',
endTime: 'endTime',
yAxis: {
label: 'Y Axis',
sqlExpression: 'y_axis',
expressionType: 'SQL',
},
tooltipMetrics: ['tooltip_metric'],
tooltipColumns: ['tooltip_column'],
series: 'series',
xAxisTimeFormat: '%H:%M',
tooltipTimeFormat: '%H:%M',
tooltipValuesFormat: 'DURATION_SEC',
colorScheme: 'bnbColors',
zoomable: true,
xAxisTitleMargin: undefined,
yAxisTitleMargin: undefined,
xAxisTimeBounds: [null, '19:00:00'],
subcategories: true,
legendMargin: 0,
legendOrientation: LegendOrientation.Top,
legendType: LegendType.Scroll,
showLegend: true,
sortSeriesAscending: true,
};
const queriesData = [
{
data: [
{
startTime: Date.UTC(2025, 1, 1, 13, 0, 0),
endTime: Date.UTC(2025, 1, 1, 14, 0, 0),
'Y Axis': 'first',
tooltip_column: 'tooltip value 1',
series: 'series value 1',
},
{
startTime: Date.UTC(2025, 1, 1, 18, 0, 0),
endTime: Date.UTC(2025, 1, 1, 20, 0, 0),
'Y Axis': 'second',
tooltip_column: 'tooltip value 2',
series: 'series value 2',
},
],
colnames: ['startTime', 'endTime', 'Y Axis', 'tooltip_column', 'series'],
},
];
const chartPropsConfig = {
formData,
queriesData,
theme: supersetTheme,
};
it('should transform chart props', () => {
const chartProps = new ChartProps(chartPropsConfig);
const transformedProps = transformProps(
chartProps as EchartsGanttChartProps,
);
expect(transformedProps.echartOptions.series).toHaveLength(4);
const series = transformedProps.echartOptions.series as any[];
const series0 = series[0];
const series1 = series[1];
// exclude renderItem because it can't be serialized
expect(typeof series0.renderItem).toBe('function');
delete series0.renderItem;
expect(typeof series1.renderItem).toBe('function');
delete series1.renderItem;
delete transformedProps.echartOptions.series;
expect(transformedProps).toEqual(
expect.objectContaining({
echartOptions: expect.objectContaining({
useUTC: true,
xAxis: {
name: '',
nameGap: 0,
nameLocation: 'middle',
max: Date.UTC(2025, 1, 1, 19, 0, 0),
min: undefined,
type: AxisType.Time,
axisLabel: {
hideOverlap: true,
formatter: expect.anything(),
},
},
yAxis: {
name: '',
nameGap: 0,
nameLocation: 'middle',
type: AxisType.Value,
// always 0
min: 0,
// equals unique categories count
max: 2,
axisLabel: {
show: false,
},
splitLine: {
show: false,
},
},
legend: expect.objectContaining({
show: true,
type: 'scroll',
selector: ['all', 'inverse'],
}),
tooltip: {
formatter: expect.anything(),
},
dataZoom: [
expect.objectContaining({
type: 'slider',
filterMode: 'none',
}),
],
}),
}),
);
expect(series0).toEqual({
name: 'series value 1',
type: 'custom',
progressive: 0,
itemStyle: {
color: expect.anything(),
},
data: [
{
value: [
Date.UTC(2025, 1, 1, 13, 0, 0),
Date.UTC(2025, 1, 1, 14, 0, 0),
0,
2,
Date.UTC(2025, 1, 1, 13, 0, 0),
Date.UTC(2025, 1, 1, 14, 0, 0),
'first',
'tooltip value 1',
'series value 1',
],
},
],
dimensions: [
'startTime',
'endTime',
'index',
'seriesCount',
'startTime',
'endTime',
'Y Axis',
'tooltip_column',
'series',
],
encode: {
x: [0, 1],
},
});
expect(series1).toEqual({
name: 'series value 2',
type: 'custom',
progressive: 0,
itemStyle: {
color: expect.anything(),
},
data: [
{
value: [
Date.UTC(2025, 1, 1, 18, 0, 0),
Date.UTC(2025, 1, 1, 20, 0, 0),
1,
2,
Date.UTC(2025, 1, 1, 18, 0, 0),
Date.UTC(2025, 1, 1, 20, 0, 0),
'second',
'tooltip value 2',
'series value 2',
],
},
],
dimensions: [
'startTime',
'endTime',
'index',
'seriesCount',
'startTime',
'endTime',
'Y Axis',
'tooltip_column',
'series',
],
encode: {
x: [0, 1],
},
});
expect(series[2]).toEqual({
// just for markLines
type: 'line',
animation: false,
markLine: {
data: [{ yAxis: 1 }, { yAxis: 0 }],
label: {
show: false,
},
silent: true,
symbol: ['none', 'none'],
lineStyle: {
type: 'dashed',
color: '#dbe0ea',
},
},
});
expect(series[3]).toEqual({
type: 'line',
animation: false,
markLine: {
data: [
{ yAxis: 1.5, name: 'first' },
{ yAxis: 0.5, name: 'second' },
],
label: {
show: true,
position: 'start',
formatter: '{b}',
},
lineStyle: expect.objectContaining({
color: '#00000000',
type: 'solid',
}),
silent: true,
symbol: ['none', 'none'],
},
});
});
});

View File

@@ -43,7 +43,7 @@
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/lodash": "^4.17.16",
"jest": "^29.7.0"
"@types/lodash": "^4.17.20",
"jest": "^30.0.2"
}
}

View File

@@ -38,10 +38,6 @@ import {
orderByControlSetItem,
orderDescendingControlSetItem,
} from './controls/orderBy';
import {
serverPageLengthControlSetItem,
serverPaginationControlSetRow,
} from './controls/pagination';
import { queryModeControlSetItem } from './controls/queryMode';
import { styleControlSetItem } from './controls/style';
@@ -57,8 +53,7 @@ const config: ControlPanelConfig = {
[percentMetricsControlSetItem],
[timeSeriesLimitMetricControlSetItem, orderByControlSetItem],
[orderDescendingControlSetItem],
serverPaginationControlSetRow,
[rowLimitControlSetItem, serverPageLengthControlSetItem],
[rowLimitControlSetItem],
[includeTimeControlSetItem],
[showTotalsControlSetItem],
['adhoc_filters'],

View File

@@ -1,53 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
ControlPanelsContainerProps,
ControlSetItem,
ControlSetRow,
} from '@superset-ui/chart-controls';
import { t } from '@superset-ui/core';
import { PAGE_SIZE_OPTIONS } from '../../consts';
export const serverPaginationControlSetRow: ControlSetRow = [
{
name: 'server_pagination',
config: {
type: 'CheckboxControl',
label: t('Server pagination'),
description: t(
'Enable server side pagination of results (experimental feature)',
),
default: false,
},
},
];
export const serverPageLengthControlSetItem: ControlSetItem = {
name: 'server_page_length',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Server Page Length'),
default: 10,
choices: PAGE_SIZE_OPTIONS,
description: t('Rows per page, 0 means no pagination'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
Boolean(controls?.server_pagination?.value),
},
};

View File

@@ -36,8 +36,8 @@
"react-dom": "^17.0.2"
},
"devDependencies": {
"@babel/types": "^7.26.9",
"@babel/types": "^7.28.0",
"@types/jest": "^29.5.12",
"jest": "^29.7.0"
"jest": "^30.0.4"
}
}

View File

@@ -29,6 +29,7 @@ import {
} from 'spec/helpers/testing-library';
import ShareSqlLabQuery from 'src/SqlLab/components/ShareSqlLabQuery';
import { initialState } from 'src/SqlLab/fixtures';
import { omit } from 'lodash';
const mockStore = configureStore([thunk]);
const defaultProps = {
@@ -110,7 +111,7 @@ describe('ShareSqlLabQuery', () => {
});
});
const button = screen.getByRole('button');
const { id: _id, remoteId: _remoteId, ...expected } = mockQueryEditor;
const expected = omit(mockQueryEditor, ['id', 'remoteId']);
userEvent.click(button);
await waitFor(() =>
expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1),
@@ -134,7 +135,7 @@ describe('ShareSqlLabQuery', () => {
});
});
const button = screen.getByRole('button');
const { id: _id, ...expected } = unsavedQueryEditor;
const expected = omit(unsavedQueryEditor, ['id']);
userEvent.click(button);
await waitFor(() =>
expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1),

View File

@@ -18,7 +18,6 @@
*/
import { useEffect, useCallback, useMemo, useState } from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import querystring from 'query-string';
import { SqlLabRootState, Table } from 'src/SqlLab/types';
import {
@@ -43,6 +42,7 @@ import {
LocalStorageKeys,
setItem,
} from 'src/utils/localStorageHelpers';
import { noop } from 'lodash';
import TableElement from '../TableElement';
export interface SqlEditorLeftBarProps {
@@ -99,8 +99,10 @@ const SqlEditorLeftBar = ({
[allSelectedTables, dbId, schema],
);
noop(_emptyResultsWithSearch); // This is to avoid unused variable warning, can be removed if not needed
useEffect(() => {
const bool = querystring.parse(window.location.search).db;
const bool = new URLSearchParams(window.location.search).get('db');
const userSelected = getItem(
LocalStorageKeys.Database,
null,

View File

@@ -1,279 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import {
AlteredSliceTag,
alterForComparison,
formatValueHandler,
isEqualish,
} from '.';
import { defaultProps } from './AlteredSliceTagMocks';
const controlsMap = {
b: { type: 'BoundsControl', label: 'Bounds' },
column_collection: { type: 'CollectionControl', label: 'Collection' },
metrics: { type: 'MetricsControl', label: 'Metrics' },
adhoc_filters: { type: 'AdhocFilterControl', label: 'Adhoc' },
other_control: { type: 'OtherControl', label: 'Other' },
};
test('renders the "Altered" label', () => {
render(
<AlteredSliceTag
origFormData={defaultProps.origFormData}
currentFormData={defaultProps.currentFormData}
/>,
);
const alteredLabel = screen.getByText('Altered');
expect(alteredLabel).toBeInTheDocument();
});
test('opens the modal on click', () => {
render(
<AlteredSliceTag
origFormData={defaultProps.origFormData}
currentFormData={defaultProps.currentFormData}
/>,
);
const alteredLabel = screen.getByText('Altered');
userEvent.click(alteredLabel);
const modalTitle = screen.getByText('Chart changes');
expect(modalTitle).toBeInTheDocument();
});
test('displays the differences in the modal', () => {
render(
<AlteredSliceTag
origFormData={defaultProps.origFormData}
currentFormData={defaultProps.currentFormData}
/>,
);
const alteredLabel = screen.getByText('Altered');
userEvent.click(alteredLabel);
const beforeValue = screen.getByText('1, 2, 3, 4');
const afterValue = screen.getByText('a, b, c, d');
expect(beforeValue).toBeInTheDocument();
expect(afterValue).toBeInTheDocument();
});
test('does not render anything if there are no differences', () => {
render(
<AlteredSliceTag
origFormData={defaultProps.origFormData}
currentFormData={defaultProps.origFormData}
/>,
);
const alteredLabel = screen.queryByText('Altered');
expect(alteredLabel).not.toBeInTheDocument();
});
test('alterForComparison returns null for undefined value', () => {
expect(alterForComparison(undefined)).toBeNull();
});
test('alterForComparison returns null for null value', () => {
expect(alterForComparison(null)).toBeNull();
});
test('alterForComparison returns null for empty string value', () => {
expect(alterForComparison('')).toBeNull();
});
test('alterForComparison returns null for empty array value', () => {
expect(alterForComparison([])).toBeNull();
});
test('alterForComparison returns null for empty object value', () => {
expect(alterForComparison({})).toBeNull();
});
test('alterForComparison returns value for non-empty array', () => {
const value = [1, 2, 3];
expect(alterForComparison(value)).toEqual(value);
});
test('alterForComparison returns value for non-empty object', () => {
const value = { key: 'value' };
expect(alterForComparison(value)).toEqual(value);
});
test('formatValueHandler handles undefined value', () => {
const value = undefined;
const key = 'b';
const formattedValue = formatValueHandler(value, key, controlsMap);
expect(formattedValue).toBe('N/A');
});
test('formatValueHandler handles null value', () => {
const value = null;
const key = 'b';
const formattedValue = formatValueHandler(value, key, controlsMap);
expect(formattedValue).toBe('null');
});
test('formatValueHandler returns "[]" for empty filters', () => {
const value = [];
const key = 'adhoc_filters';
const formattedValue = formatValueHandler(value, key, controlsMap);
expect(formattedValue).toBe('[]');
});
test('formatValueHandler formats filters with array values', () => {
const filters = [
{
clause: 'WHERE',
comparator: ['1', 'g', '7', 'ho'],
expressionType: 'SIMPLE',
operator: 'IN',
subject: 'a',
},
{
clause: 'WHERE',
comparator: ['hu', 'ho', 'ha'],
expressionType: 'SIMPLE',
operator: 'NOT IN',
subject: 'b',
},
];
const key = 'adhoc_filters';
const formattedValue = formatValueHandler(filters, key, controlsMap);
const expected = 'a IN [1, g, 7, ho], b NOT IN [hu, ho, ha]';
expect(formattedValue).toBe(expected);
});
test('formatValueHandler formats filters with string values', () => {
const filters = [
{
clause: 'WHERE',
comparator: 'gucci',
expressionType: 'SIMPLE',
operator: '==',
subject: 'a',
},
{
clause: 'WHERE',
comparator: 'moshi moshi',
expressionType: 'SIMPLE',
operator: 'LIKE',
subject: 'b',
},
];
const key = 'adhoc_filters';
const expected = 'a == gucci, b LIKE moshi moshi';
const formattedValue = formatValueHandler(filters, key, controlsMap);
expect(formattedValue).toBe(expected);
});
test('formatValueHandler formats "Min" and "Max" for BoundsControl', () => {
const value = [1, 2];
const key = 'b';
const result = formatValueHandler(value, key, controlsMap);
expect(result).toEqual('Min: 1, Max: 2');
});
test('formatValueHandler formats stringified objects for CollectionControl', () => {
const value = [{ a: 1 }, { b: 2 }];
const key = 'column_collection';
const result = formatValueHandler(value, key, controlsMap);
expect(result).toEqual(
`${JSON.stringify(value[0])}, ${JSON.stringify(value[1])}`,
);
});
test('formatValueHandler formats MetricsControl values correctly', () => {
const value = [{ label: 'SUM(Sales)' }, { label: 'Metric2' }];
const key = 'metrics';
const result = formatValueHandler(value, key, controlsMap);
expect(result).toEqual('SUM(Sales), Metric2');
});
test('formatValueHandler formats boolean values as string', () => {
const value1 = true;
const value2 = false;
const key = 'b';
const formattedValue1 = formatValueHandler(value1, key, controlsMap);
const formattedValue2 = formatValueHandler(value2, key, controlsMap);
expect(formattedValue1).toBe('true');
expect(formattedValue2).toBe('false');
});
test('formatValueHandler formats array values correctly', () => {
const value = [
{ label: 'Label1' },
{ label: 'Label2' },
5,
6,
7,
8,
'hello',
'goodbye',
];
const result = formatValueHandler(value, undefined, controlsMap);
const expected = 'Label1, Label2, 5, 6, 7, 8, hello, goodbye';
expect(result).toEqual(expected);
});
test('formatValueHandler formats string values correctly', () => {
const value = 'test';
const key = 'other_control';
const result = formatValueHandler(value, key, controlsMap);
expect(result).toEqual('test');
});
test('formatValueHandler formats number values correctly', () => {
const value = 123;
const key = 'other_control';
const result = formatValueHandler(value, key, controlsMap);
expect(result).toEqual(123);
});
test('formatValueHandler formats object values correctly', () => {
const value = { 1: 2, alpha: 'bravo' };
const key = 'other_control';
const expected = '{"1":2,"alpha":"bravo"}';
const result = formatValueHandler(value, key, controlsMap);
expect(result).toEqual(expected);
});
test('isEqualish considers null, undefined, {} and [] as equal', () => {
expect(isEqualish(null, undefined)).toBe(true);
expect(isEqualish(null, [])).toBe(true);
expect(isEqualish(null, {})).toBe(true);
expect(isEqualish(undefined, {})).toBe(true);
});
test('isEqualish considers empty strings equal to null', () => {
expect(isEqualish(undefined, '')).toBe(true);
expect(isEqualish(null, '')).toBe(true);
});
test('isEqualish considers deeply equal objects equal', () => {
const obj1 = { a: { b: { c: 1 } } };
const obj2 = { a: { b: { c: 1 } } };
expect(isEqualish('', '')).toBe(true);
expect(isEqualish(obj1, obj2)).toBe(true);
// Actually not equal
expect(isEqualish({ a: 1, b: 2, z: 9 }, { a: 1, b: 2, c: 3 })).toBe(false);
});

View File

@@ -0,0 +1,97 @@
/**
* 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 { QueryFormData } from '@superset-ui/core';
import { AlteredSliceTag } from '.';
import { defaultProps, expectedDiffs } from './AlteredSliceTagMocks';
test('renders the "Altered" label', () => {
render(
<AlteredSliceTag
origFormData={defaultProps.origFormData}
currentFormData={defaultProps.currentFormData}
diffs={expectedDiffs}
/>,
);
const alteredLabel: HTMLElement = screen.getByText('Altered');
expect(alteredLabel).toBeInTheDocument();
});
test('opens the modal on click', () => {
render(
<AlteredSliceTag
origFormData={defaultProps.origFormData}
currentFormData={defaultProps.currentFormData}
diffs={expectedDiffs}
/>,
);
const alteredLabel: HTMLElement = screen.getByText('Altered');
userEvent.click(alteredLabel);
const modalTitle: HTMLElement = screen.getByText('Chart changes');
expect(modalTitle).toBeInTheDocument();
});
test('displays the differences in the modal', () => {
render(
<AlteredSliceTag
origFormData={defaultProps.origFormData}
currentFormData={defaultProps.currentFormData}
diffs={expectedDiffs}
/>,
);
const alteredLabel: HTMLElement = screen.getByText('Altered');
userEvent.click(alteredLabel);
const beforeValue: HTMLElement = screen.getByText('1, 2, 3, 4');
const afterValue: HTMLElement = screen.getByText('a, b, c, d');
expect(beforeValue).toBeInTheDocument();
expect(afterValue).toBeInTheDocument();
});
test('does not render anything if there are no differences', () => {
render(
<AlteredSliceTag
origFormData={defaultProps.origFormData}
currentFormData={defaultProps.origFormData}
diffs={{}}
/>,
);
const alteredLabel: HTMLElement | null = screen.queryByText('Altered');
expect(alteredLabel).not.toBeInTheDocument();
});
test('does not render the altered label if diffs is empty', () => {
render(
<AlteredSliceTag
origFormData={{ viz_type: 'altered_slice_tag_spec' } as QueryFormData}
currentFormData={{ viz_type: 'altered_slice_tag_spec' } as QueryFormData}
diffs={{}}
/>,
);
expect(screen.queryByText('Altered')).not.toBeInTheDocument();
});

View File

@@ -18,10 +18,16 @@
*/
import { QueryFormData } from '@superset-ui/core';
import { ControlPanelConfig } from '@superset-ui/chart-controls';
import type { DiffType, RowType } from './types';
import type { DiffType } from 'src/types/DiffType';
import { getChartFormDiffs } from 'src/utils/getChartFormDiffs';
import type { RowType } from './types';
export const defaultProps: Record<string, Partial<QueryFormData>> = {
export const defaultProps: {
origFormData: QueryFormData;
currentFormData: QueryFormData;
} = {
origFormData: {
datasource: '123__table',
viz_type: 'altered_slice_tag_spec',
adhoc_filters: [
{
@@ -40,7 +46,10 @@ export const defaultProps: Record<string, Partial<QueryFormData>> = {
never: 5,
ever: { a: 'b', c: 'd' },
},
currentFormData: {
datasource: '123__table',
viz_type: 'altered_slice_tag_spec',
adhoc_filters: [
{
clause: 'WHERE',
@@ -106,6 +115,7 @@ export const expectedDiffs: Record<string, DiffType> = {
after: { x: 'y', z: 'z' },
},
};
export const expectedRows: RowType[] = [
{
control: 'Fake Filters',
@@ -131,6 +141,7 @@ export const expectedRows: RowType[] = [
after: '{"x":"y","z":"z"}',
},
];
export const fakePluginControls: ControlPanelConfig = {
controlPanelSections: [
{
@@ -175,3 +186,6 @@ export const fakePluginControls: ControlPanelConfig = {
},
],
};
export const createDiffs = (original: QueryFormData, current: QueryFormData) =>
getChartFormDiffs(original, current);

View File

@@ -16,13 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useMemo, useState, FC } from 'react';
import { isEqual, isEmpty } from 'lodash';
import { useEffect, useMemo, useState, FC } from 'react';
import { isEmpty } from 'lodash';
import { t } from '@superset-ui/core';
import { sanitizeFormData } from 'src/explore/exploreUtils/formData';
import getControlsForVizType from 'src/utils/getControlsForVizType';
import { safeStringify } from 'src/utils/safeStringify';
import {
Label,
Icons,
@@ -30,130 +27,21 @@ import {
ModalTrigger,
TableView,
} from '@superset-ui/core/components';
import type {
AlteredSliceTagProps,
ControlMap,
DiffItemType,
DiffType,
FilterItemType,
RowType,
} from './types';
export const alterForComparison = (
value?: string | null | [],
): string | null => {
// Treat `null`, `undefined`, and empty strings as equivalent
if (value === undefined || value === null || value === '') {
return null;
}
// Treat empty arrays and objects as equivalent to null
if (Array.isArray(value) && value.length === 0) {
return null;
}
if (typeof value === 'object' && Object.keys(value).length === 0) {
return null;
}
return value;
};
export const formatValueHandler = (
value: DiffItemType,
key: string,
controlsMap: ControlMap,
): string | number => {
if (value === undefined) {
return 'N/A';
}
if (value === null) {
return 'null';
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
if (controlsMap[key]?.type === 'AdhocFilterControl' && Array.isArray(value)) {
if (!value.length) {
return '[]';
}
return value
.map((v: FilterItemType) => {
const filterVal =
v.comparator && v.comparator.constructor === Array
? `[${v.comparator.join(', ')}]`
: v.comparator;
return `${v.subject} ${v.operator} ${filterVal}`;
})
.join(', ');
}
if (controlsMap[key]?.type === 'BoundsControl' && Array.isArray(value)) {
return `Min: ${value[0]}, Max: ${value[1]}`;
}
if (controlsMap[key]?.type === 'CollectionControl' && Array.isArray(value)) {
return value.map((v: FilterItemType) => safeStringify(v)).join(', ');
}
if (
controlsMap[key]?.type === 'MetricsControl' &&
value.constructor === Array
) {
const formattedValue = value.map((v: FilterItemType) => v?.label ?? v);
return formattedValue.length ? formattedValue.join(', ') : '[]';
}
if (Array.isArray(value)) {
const formattedValue = value.map((v: FilterItemType) => v?.label ?? v);
return formattedValue.length ? formattedValue.join(', ') : '[]';
}
if (typeof value === 'string' || typeof value === 'number') {
return value;
}
return safeStringify(value);
};
export const getRowsFromDiffs = (
diffs: { [key: string]: DiffType },
controlsMap: ControlMap,
): RowType[] =>
Object.entries(diffs).map(([key, diff]) => ({
control: controlsMap[key]?.label || key,
before: formatValueHandler(diff.before, key, controlsMap),
after: formatValueHandler(diff.after, key, controlsMap),
}));
export const isEqualish = (val1: string, val2: string): boolean =>
isEqual(alterForComparison(val1), alterForComparison(val2));
import type { AlteredSliceTagProps, ControlMap, RowType } from './types';
import { getRowsFromDiffs } from './utils';
export const AlteredSliceTag: FC<AlteredSliceTagProps> = props => {
const [rows, setRows] = useState<RowType[]>([]);
const [hasDiffs, setHasDiffs] = useState<boolean>(false);
const getDiffs = useCallback(() => {
// Returns all properties that differ in the
// current form data and the saved form data
const ofd = sanitizeFormData(props.origFormData);
const cfd = sanitizeFormData(props.currentFormData);
const fdKeys = Object.keys(cfd);
const diffs: { [key: string]: DiffType } = {};
fdKeys.forEach(fdKey => {
if (!ofd[fdKey] && !cfd[fdKey]) {
return;
}
if (['filters', 'having', 'where'].includes(fdKey)) {
return;
}
if (!isEqualish(ofd[fdKey], cfd[fdKey])) {
diffs[fdKey] = { before: ofd[fdKey], after: cfd[fdKey] };
}
});
return diffs;
}, [props.currentFormData, props.origFormData]);
useEffect(() => {
const diffs = getDiffs();
const controlsMap = getControlsForVizType(
props.origFormData?.viz_type,
) as ControlMap;
setRows(getRowsFromDiffs(diffs, controlsMap));
setHasDiffs(!isEmpty(diffs));
}, [getDiffs, props.origFormData?.viz_type]);
setRows(getRowsFromDiffs(props.diffs, controlsMap));
setHasDiffs(!isEmpty(props.diffs));
}, [props.diffs, props.origFormData?.viz_type]);
const modalBody = useMemo(() => {
const columns = [

View File

@@ -17,8 +17,11 @@
* under the License.
*/
import type { QueryFormData } from '@superset-ui/core';
import { DiffType } from 'src/types/DiffType';
export interface AlteredSliceTagProps {
className?: string;
diffs: Record<string, DiffType>;
origFormData: QueryFormData;
currentFormData: QueryFormData;
}
@@ -30,29 +33,6 @@ export interface ControlMap {
};
}
export type FilterItemType = {
comparator?: string | string[];
subject: string;
operator: string;
label?: string;
};
export type DiffItemType<
T = FilterItemType | number | string | Record<string | number, any>,
> =
| T[]
| boolean
| number
| string
| Record<string | number, any>
| null
| undefined;
export type DiffType = {
before: DiffItemType;
after: DiffItemType;
};
export type RowType = {
before: string | number;
after: string | number;

View File

@@ -0,0 +1,107 @@
/**
* 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 { DiffItemType, DiffType, FilterItemType } from 'src/types/DiffType';
import { safeStringify } from 'src/utils/safeStringify';
import { ControlMap, RowType } from '../types';
export const alterForComparison = (value?: unknown): unknown => {
// Treat `null`, `undefined`, and empty strings as equivalent
if (value === undefined || value === null || value === '') return null;
// Treat empty arrays and objects as equivalent to null
if (Array.isArray(value) && value.length === 0) return null;
if (typeof value === 'object' && Object.keys(value).length === 0) return null;
return value;
};
export const formatValueHandler = (
value: DiffItemType,
key = '',
controlsMap: ControlMap,
): string | number => {
if (value === undefined) return 'N/A';
if (value === null) return 'null';
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (controlsMap[key]?.type === 'AdhocFilterControl' && Array.isArray(value)) {
if (!value.length) return '[]';
return value
.map((v: FilterItemType): string => {
const filterVal: string | string[] | undefined =
v.comparator && v.comparator.constructor === Array
? `[${v.comparator.join(', ')}]`
: v.comparator;
return `${v.subject} ${v.operator} ${filterVal}`;
})
.join(', ');
}
if (controlsMap[key]?.type === 'BoundsControl' && Array.isArray(value))
return `Min: ${value[0]}, Max: ${value[1]}`;
if (controlsMap[key]?.type === 'CollectionControl' && Array.isArray(value))
return value
.map((v: FilterItemType): string => safeStringify(v))
.join(', ');
if (
controlsMap[key]?.type === 'MetricsControl' &&
value.constructor === Array
) {
const formattedValue: (string | FilterItemType)[] = value.map(
(v: FilterItemType): string | FilterItemType => v?.label ?? v,
);
return formattedValue.length ? formattedValue.join(', ') : '[]';
}
if (Array.isArray(value)) {
const formattedValue = value.map((v: any) => {
if (
typeof v === 'object' &&
v !== null &&
'label' in v &&
typeof v.label === 'string'
)
return v.label;
return String(v);
});
return formattedValue.length ? formattedValue.join(', ') : '[]';
}
if (typeof value === 'string' || typeof value === 'number') return value;
return safeStringify(value);
};
export const getRowsFromDiffs = (
diffs: { [key: string]: DiffType },
controlsMap: ControlMap,
): RowType[] =>
Object.entries(diffs).map(([key, diff]: [string, DiffType]) => ({
control: controlsMap[key]?.label || key,
before: formatValueHandler(diff.before, key, controlsMap),
after: formatValueHandler(diff.after, key, controlsMap),
}));

View File

@@ -0,0 +1,291 @@
/**
* 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 { alterForComparison, formatValueHandler, getRowsFromDiffs } from '.';
import { RowType } from '../types';
describe('alterForComparison', () => {
it('returns null for undefined value', () => {
expect(alterForComparison(undefined)).toBeNull();
});
it('returns null for null value', () => {
expect(alterForComparison(null)).toBeNull();
});
it('returns null for empty string value', () => {
expect(alterForComparison('')).toBeNull();
});
it('returns null for empty array value', () => {
expect(alterForComparison([])).toBeNull();
});
it('returns null for empty object value', () => {
expect(alterForComparison({})).toBeNull();
});
it('returns value for non-empty array', () => {
const value = [1, 2, 3];
expect(alterForComparison(value)).toEqual(value);
});
it('returns value for non-empty object', () => {
const value = { key: 'value' };
expect(alterForComparison(value)).toEqual(value);
});
});
describe('formatValueHandler', () => {
const controlsMap = {
b: { type: 'BoundsControl', label: 'Bounds' },
column_collection: { type: 'CollectionControl', label: 'Collection' },
metrics: { type: 'MetricsControl', label: 'Metrics' },
adhoc_filters: { type: 'AdhocFilterControl', label: 'Adhoc' },
other_control: { type: 'OtherControl', label: 'Other' },
};
it('handles undefined value', () => {
const value = undefined;
const key = 'b';
const formattedValue: string | number = formatValueHandler(
value,
key,
controlsMap,
);
expect(formattedValue).toBe('N/A');
});
it('handles null value', () => {
const value = null;
const key = 'b';
const formattedValue: string | number = formatValueHandler(
value,
key,
controlsMap,
);
expect(formattedValue).toBe('null');
});
it('returns "[]" for empty filters', () => {
const value: unknown[] = [];
const key = 'adhoc_filters';
const formattedValue: string | number = formatValueHandler(
value,
key,
controlsMap,
);
expect(formattedValue).toBe('[]');
});
it('formats filters with array values', () => {
const filters = [
{
clause: 'WHERE',
comparator: ['1', 'g', '7', 'ho'],
expressionType: 'SIMPLE',
operator: 'IN',
subject: 'a',
},
{
clause: 'WHERE',
comparator: ['hu', 'ho', 'ha'],
expressionType: 'SIMPLE',
operator: 'NOT IN',
subject: 'b',
},
];
const key = 'adhoc_filters';
const expected = 'a IN [1, g, 7, ho], b NOT IN [hu, ho, ha]';
const formattedValue: string | number = formatValueHandler(
filters,
key,
controlsMap,
);
expect(formattedValue).toBe(expected);
});
it('formats filters with string values', () => {
const filters = [
{
clause: 'WHERE',
comparator: 'gucci',
expressionType: 'SIMPLE',
operator: '==',
subject: 'a',
},
{
clause: 'WHERE',
comparator: 'moshi moshi',
expressionType: 'SIMPLE',
operator: 'LIKE',
subject: 'b',
},
];
const key = 'adhoc_filters';
const expected = 'a == gucci, b LIKE moshi moshi';
const formattedValue: string | number = formatValueHandler(
filters,
key,
controlsMap,
);
expect(formattedValue).toBe(expected);
});
it('formats "Min" and "Max" for BoundsControl', () => {
const value: number[] = [1, 2];
const key = 'b';
const result: string | number = formatValueHandler(value, key, controlsMap);
expect(result).toEqual('Min: 1, Max: 2');
});
it('formats stringified objects for CollectionControl', () => {
const value = [{ a: 1 }, { b: 2 }];
const key = 'column_collection';
const result: string | number = formatValueHandler(value, key, controlsMap);
expect(result).toEqual(
`${JSON.stringify(value[0])}, ${JSON.stringify(value[1])}`,
);
});
it('formats MetricsControl values correctly', () => {
const value = [{ label: 'SUM(Sales)' }, { label: 'Metric2' }];
const key = 'metrics';
const result: string | number = formatValueHandler(value, key, controlsMap);
expect(result).toEqual('SUM(Sales), Metric2');
});
it('formats boolean values as string', () => {
const value1 = true;
const value2 = false;
const key = 'b';
const formattedValue1: string | number = formatValueHandler(
value1,
key,
controlsMap,
);
const formattedValue2: string | number = formatValueHandler(
value2,
key,
controlsMap,
);
expect(formattedValue1).toBe('true');
expect(formattedValue2).toBe('false');
});
it('formats array values correctly', () => {
const value = [
{ label: 'Label1' },
{ label: 'Label2' },
5,
6,
7,
8,
'hello',
'goodbye',
];
const expected = 'Label1, Label2, 5, 6, 7, 8, hello, goodbye';
const result: string | number = formatValueHandler(
value,
undefined,
controlsMap,
);
expect(result).toEqual(expected);
});
it('formats string values correctly', () => {
const value = 'test';
const key = 'other_control';
const result: string | number = formatValueHandler(value, key, controlsMap);
expect(result).toEqual('test');
});
it('formats number values correctly', () => {
const value = 123;
const key = 'other_control';
const result: string | number = formatValueHandler(value, key, controlsMap);
expect(result).toEqual(123);
});
it('formats object values correctly', () => {
const value = { 1: 2, alpha: 'bravo' };
const key = 'other_control';
const expected = '{"1":2,"alpha":"bravo"}';
const result: string | number = formatValueHandler(value, key, controlsMap);
expect(result).toEqual(expected);
});
});
describe('getRowsFromDiffs', () => {
it('returns formatted rows for diffs', () => {
const diffs = {
metric: { before: [{ label: 'old' }], after: [{ label: 'new' }] },
limit: { before: 10, after: 20 },
};
const controlsMap = {
metric: { label: 'Metric', type: 'MetricsControl' },
limit: { label: 'Row Limit', type: 'TextControl' },
};
const rows: RowType[] = getRowsFromDiffs(diffs, controlsMap);
expect(rows).toEqual([
{ control: 'Metric', before: 'old', after: 'new' },
{ control: 'Row Limit', before: 10, after: 20 },
]);
});
it('falls back to key if label is missing', () => {
const diffs = {
unknown: { before: 'a', after: 'b' },
};
const controlsMap = {};
const rows: RowType[] = getRowsFromDiffs(diffs, controlsMap);
expect(rows).toEqual([{ control: 'unknown', before: 'a', after: 'b' }]);
});
});

View File

@@ -164,6 +164,8 @@ class ChartRenderer extends Component {
nextProps.labelsColorMap !== this.props.labelsColorMap ||
nextProps.formData.color_scheme !== this.props.formData.color_scheme ||
nextProps.formData.stack !== this.props.formData.stack ||
nextProps.formData.subcategories !==
this.props.formData.subcategories ||
nextProps.cacheBusterProp !== this.props.cacheBusterProp ||
nextProps.emitCrossFilters !== this.props.emitCrossFilters
);

View File

@@ -35,7 +35,6 @@ interface DatabaseErrorExtra {
export function DatabaseErrorMessage({
error,
source,
subtitle,
}: ErrorMessageComponentProps<DatabaseErrorExtra | null>) {
const { extra, level, message } = error;

View File

@@ -33,7 +33,6 @@ interface SupersetParseErrorExtra {
*/
export function InvalidSQLErrorMessage({
error,
source,
subtitle,
}: ErrorMessageComponentProps<SupersetParseErrorExtra>) {
const { extra, level, message } = error;

View File

@@ -54,7 +54,6 @@ const findMatches = (undefinedParameters: string[], templateKeys: string[]) => {
export function ParameterErrorMessage({
error,
source = 'sqllab',
subtitle,
}: ErrorMessageComponentProps<ParameterErrorExtra>) {
const { extra = { issue_codes: [] }, level, message } = error;

View File

@@ -0,0 +1,33 @@
/**
* 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 {
TimePicker as AntdTimePicker,
TimePickerProps,
TimeRangePickerProps,
} from 'antd';
const commonCss = { width: '100%' };
export const TimePicker = (props: TimePickerProps) => (
<AntdTimePicker css={commonCss} {...props} />
);
export const TimeRangePicker = (props: TimeRangePickerProps) => (
<AntdTimePicker.RangePicker css={commonCss} {...props} />
);

View File

@@ -103,8 +103,12 @@ class CssEditor extends PureComponent<CssEditorProps, CssEditorState> {
}
changeCssTemplate(info: { key: Key }) {
const keyAsString = String(info.key);
this.changeCss(keyAsString);
const selectedTemplate = this.state.templates?.find(
template => template.label === info.key,
);
if (selectedTemplate) {
this.changeCss(selectedTemplate.css);
}
}
renderTemplateSelector() {
@@ -113,7 +117,7 @@ class CssEditor extends PureComponent<CssEditorProps, CssEditorState> {
<Menu
onClick={this.changeCssTemplate}
items={this.state.templates.map(template => ({
key: template.css,
key: template.label,
label: template.label,
}))}
/>

View File

@@ -291,9 +291,9 @@ const StyledDashboardContent = styled.div<{
width: 0;
flex: 1;
position: relative;
margin-top: ${theme.sizeUnit * 6}px;
margin-top: ${theme.sizeUnit * 4}px;
margin-right: ${theme.sizeUnit * 8}px;
margin-bottom: ${theme.sizeUnit * 6}px;
margin-bottom: ${theme.sizeUnit * 4}px;
margin-left: ${marginLeft}px;
${editMode &&

View File

@@ -30,7 +30,7 @@ import {
export const useNativeFilters = () => {
const [isInitialized, setIsInitialized] = useState(false);
const showNativeFilters = useSelector<RootState, boolean>(
state => getUrlParam(URL_PARAMS.showFilters) ?? true,
() => getUrlParam(URL_PARAMS.showFilters) ?? true,
);
const canEdit = useSelector<RootState, boolean>(
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,

View File

@@ -17,11 +17,13 @@
* under the License.
*/
import * as redux from 'redux';
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
import {
render,
screen,
fireEvent,
userEvent,
within,
} from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
import { getExtensionsRegistry, JsonObject } from '@superset-ui/core';
@@ -166,6 +168,10 @@ const onRefresh = jest.fn();
const dashboardInfoChanged = jest.fn();
const dashboardTitleChanged = jest.fn();
jest.mock('src/hooks/useUnsavedChangesPrompt', () => ({
useUnsavedChangesPrompt: jest.fn(),
}));
beforeAll(() => {
jest.spyOn(redux, 'bindActionCreators').mockImplementation(() => ({
addSuccessToast,
@@ -195,9 +201,14 @@ beforeAll(() => {
beforeEach(() => {
jest.clearAllMocks();
});
beforeEach(() => {
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: false,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
});
window.history.pushState({}, 'Test page', '/dashboard?standalone=1');
});
@@ -484,3 +495,91 @@ test('should render MetadataBar when not in edit mode and not embedded', () => {
screen.getByText(state.dashboardInfo.changed_on_delta_humanized),
).toBeInTheDocument();
});
test('should show UnsavedChangesModal when there are unsaved changes and user tries to navigate', async () => {
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: true,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
});
setup({ ...editableState });
const modalTitle: HTMLElement = await screen.findByText(
'Save changes to your dashboard?',
);
const modalBody: HTMLElement = await screen.findByText(
"If you don't save, changes will be lost.",
);
expect(modalTitle).toBeInTheDocument();
expect(modalBody).toBeInTheDocument();
});
test('should call handleSaveAndCloseModal when Save is clicked in UnsavedChangesModal', async () => {
const handleSaveAndCloseModal = jest.fn();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: true,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal,
});
setup({ ...editableState });
const modal: HTMLElement = await screen.findByRole('dialog');
const saveButton: HTMLElement = within(modal).getByRole('button', {
name: /save/i,
});
userEvent.click(saveButton);
expect(handleSaveAndCloseModal).toHaveBeenCalled();
});
test('should call handleConfirmNavigation when user confirms navigation in UnsavedChangesModal', async () => {
const handleConfirmNavigation = jest.fn();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: true,
setShowModal: jest.fn(),
handleConfirmNavigation,
handleSaveAndCloseModal: jest.fn(),
});
setup({ ...editableState });
const modal: HTMLElement = await screen.findByRole('dialog');
const discardButton: HTMLElement = within(modal).getByRole('button', {
name: /discard/i,
});
userEvent.click(discardButton);
expect(handleConfirmNavigation).toHaveBeenCalled();
});
test('should call setShowUnsavedChangesModal(false) on cancel', async () => {
const setShowModal = jest.fn();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: true,
setShowModal,
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
});
setup({ ...editableState });
const modal: HTMLElement = await screen.findByRole('dialog');
const closeButton: HTMLElement = within(modal).getByRole('button', {
name: /close/i,
});
userEvent.click(closeButton);
expect(setShowModal).toHaveBeenCalledWith(false);
});

View File

@@ -36,7 +36,12 @@ import {
LOG_ACTIONS_TOGGLE_EDIT_DASHBOARD,
} from 'src/logger/LogUtils';
import { Icons } from '@superset-ui/core/components/Icons';
import { Button, Tooltip, DeleteModal } from '@superset-ui/core/components';
import {
Button,
Tooltip,
DeleteModal,
UnsavedChangesModal,
} from '@superset-ui/core/components';
import { findPermission } from 'src/utils/findPermission';
import { safeStringify } from 'src/utils/safeStringify';
import PublishedStatus from 'src/dashboard/components/PublishedStatus';
@@ -54,6 +59,7 @@ import setPeriodicRunner, {
import ReportModal from 'src/features/reports/ReportModal';
import { deleteActiveReport } from 'src/features/reports/ReportModal/actions';
import { PageHeaderWithActions } from '@superset-ui/core/components/PageHeaderWithActions';
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
import DashboardEmbedModal from '../EmbeddedModal';
import OverwriteConfirm from '../OverwriteConfirm';
import {
@@ -460,6 +466,16 @@ const Header = () => {
slug,
]);
const {
showModal: showUnsavedChangesModal,
setShowModal: setShowUnsavedChangesModal,
handleConfirmNavigation,
handleSaveAndCloseModal,
} = useUnsavedChangesPrompt({
hasUnsavedChanges,
onSave: overwriteDashboard,
});
const showPropertiesModal = useCallback(() => {
setShowingPropertiesModal(true);
}, []);
@@ -816,6 +832,15 @@ const Header = () => {
}
`}
/>
<UnsavedChangesModal
title={t('Save changes to your dashboard?')}
body={t("If you don't save, changes will be lost.")}
showModal={showUnsavedChangesModal}
onHide={() => setShowUnsavedChangesModal(false)}
onConfirmNavigation={handleConfirmNavigation}
handleSave={handleSaveAndCloseModal}
/>
</div>
);
};

View File

@@ -88,7 +88,7 @@ const VerticalDotsTrigger = () => {
cursor: pointer;
}
`}
iconSize="xxl"
iconSize="xl"
iconColor={theme.colorTextLabel}
className="dot"
/>
@@ -522,6 +522,7 @@ const SliceHeaderControls = (
aria-haspopup="true"
css={theme => css`
padding: ${theme.sizeUnit * 2}px;
padding-right: 0px;
`}
>
<VerticalDotsTrigger />

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