mirror of
https://github.com/apache/superset.git
synced 2026-07-19 21:25:38 +00:00
Compare commits
33 Commits
faq_filter
...
plavacquer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1f6981b9c | ||
|
|
9f0523977d | ||
|
|
cb6342fc73 | ||
|
|
5214ee6fd4 | ||
|
|
7b3329f315 | ||
|
|
d1b372f670 | ||
|
|
09c657c899 | ||
|
|
24500e99f8 | ||
|
|
b2a173977e | ||
|
|
8be79f4170 | ||
|
|
debaf8d6e9 | ||
|
|
1133d84775 | ||
|
|
8c4ca60b28 | ||
|
|
90eded1a04 | ||
|
|
093ee37aac | ||
|
|
23b1fe3b9e | ||
|
|
42288c4784 | ||
|
|
c008190a08 | ||
|
|
2734688f4f | ||
|
|
e3d8326d81 | ||
|
|
e1ab27f484 | ||
|
|
d1d43be9d1 | ||
|
|
f9cec3e366 | ||
|
|
f92431176a | ||
|
|
c31daf8c92 | ||
|
|
c74fae9663 | ||
|
|
308007f909 | ||
|
|
9aaab7374e | ||
|
|
057218d87f | ||
|
|
050ccdcb3d | ||
|
|
09d975cc3f | ||
|
|
95b6236ec9 | ||
|
|
22d12f3b24 |
4
.github/dependabot.yml
vendored
4
.github/dependabot.yml
vendored
@@ -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"
|
||||
|
||||
2
.github/workflows/pre-commit.yml
vendored
2
.github/workflows/pre-commit.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
78
docs/docs/configuration/map-tiles.mdx
Normal file
78
docs/docs/configuration/map-tiles.mdx
Normal 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",
|
||||
],
|
||||
...
|
||||
}
|
||||
```
|
||||
@@ -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 filter’s 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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ module.exports = {
|
||||
setupFilesAfterEnv: ['<rootDir>/spec/helpers/setup.ts'],
|
||||
snapshotSerializers: ['@emotion/jest/serializer'],
|
||||
testEnvironmentOptions: {
|
||||
globalsCleanup: true,
|
||||
url: 'http://localhost',
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
|
||||
15845
superset-frontend/package-lock.json
generated
15845
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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...)',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
|
||||
|
||||
import { ExtensibleFunction } from '@superset-ui/core';
|
||||
|
||||
describe('ExtensibleFunction', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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...)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/',
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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 |
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>],
|
||||
|
||||
@@ -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>],
|
||||
[
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>],
|
||||
|
||||
@@ -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>],
|
||||
|
||||
@@ -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>],
|
||||
|
||||
@@ -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>],
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
107
superset-frontend/src/components/AlteredSliceTag/utils/index.ts
Normal file
107
superset-frontend/src/components/AlteredSliceTag/utils/index.ts
Normal 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),
|
||||
}));
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -35,7 +35,6 @@ interface DatabaseErrorExtra {
|
||||
export function DatabaseErrorMessage({
|
||||
error,
|
||||
source,
|
||||
subtitle,
|
||||
}: ErrorMessageComponentProps<DatabaseErrorExtra | null>) {
|
||||
const { extra, level, message } = error;
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ interface SupersetParseErrorExtra {
|
||||
*/
|
||||
export function InvalidSQLErrorMessage({
|
||||
error,
|
||||
source,
|
||||
subtitle,
|
||||
}: ErrorMessageComponentProps<SupersetParseErrorExtra>) {
|
||||
const { extra, level, message } = error;
|
||||
|
||||
@@ -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;
|
||||
|
||||
33
superset-frontend/src/components/TimePicker/index.tsx
Normal file
33
superset-frontend/src/components/TimePicker/index.tsx
Normal 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} />
|
||||
);
|
||||
@@ -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,
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user