Compare commits

...

45 Commits

Author SHA1 Message Date
Beto Dealmeida
8013b32f0e chore: remove is_select_query (#33457) 2025-05-22 20:53:22 -04:00
Beto Dealmeida
adeed60fe0 feat: implement limit extraction in sqlglot (#33456) 2025-05-22 20:09:36 -04:00
Vitor Avila
546945e7a6 fix(AllEntities): Display action buttons according to the user permissions (#33553) 2025-05-22 16:01:26 -03:00
Giampaolo Capelli
5b2f1bbf9e feat(stack by dimension): add a stack by dimension dropdown list (#32707)
Co-authored-by: CAPELLI Giampaolo <giampaolo.capelli@docaposte.fr>
2025-05-22 11:10:18 -03:00
Beto Dealmeida
875f538d54 fix: text => JSON migration util (#33516) 2025-05-22 08:41:38 -04:00
Mike Klumpenaar
b7d3ff1e85 fix(user settings): Update forked cosmo theme to resolve down chevron in caret style (#30514) (#30577)
Co-authored-by: garriscp <garriscp@gmail.com>
2025-05-21 12:32:51 -06:00
Beto Dealmeida
c03964dc5f chore: remove useless-suppression (#33549) 2025-05-21 14:11:58 -04:00
amaannawab923
950a3313d8 fix(table): table sort by fix (#33540)
Co-authored-by: Amaan Nawab <nelsondrew07@gmail.com>
Co-authored-by: Geido <60598000+geido@users.noreply.github.com>
2025-05-21 15:00:25 +02:00
Geido
e2a22d481c fix(Select): Add buttonStyle prop for backward compatibility (#33543) 2025-05-20 18:40:23 +02:00
Rafael Benitez
b4e2406385 fix(Sqllab): Autocomplete got stuck in UI when open it too fast (#33522) 2025-05-20 16:38:55 +02:00
Geido
ca9e74edd8 chore(Icons): Additional Ant Design Icons (#33539) 2025-05-20 14:05:18 +02:00
Evan Rusackas
39b3de6b5d fix(CI): adding explicit allowable licenses for python dependencies (#33521) 2025-05-19 15:54:01 -06:00
Maxime Beauchemin
26563bb330 fix: optimize Explore popovers rendering (#33501) 2025-05-19 13:58:42 -07:00
Damian Pendrak
0653e123cc feat(chart): add dynamicQueryObjectCount property to Chart Metadata (#33451) 2025-05-19 14:54:57 +02:00
Alexandru Soare
76358ed64e chore(fab): bumped fab from 4.6.3 to 4.6.4 (#33469) 2025-05-19 12:16:18 +02:00
amaannawab923
217f11a8f7 fix(table): table ui fixes (#33494)
Co-authored-by: Amaan Nawab <nelsondrew07@gmail.com>
2025-05-17 13:48:49 -07:00
dependabot[bot]
af21ef2497 chore(deps): bump ace-builds from 1.37.5 to 1.41.0 in /superset-frontend (#33498)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 13:54:11 +07:00
dependabot[bot]
51c25831e8 chore(deps): bump debug from 4.4.0 to 4.4.1 in /superset-websocket/utils/client-ws-app (#33476)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 11:33:46 +07:00
dependabot[bot]
be41e0526a chore(deps-dev): bump eslint-config-prettier from 10.1.2 to 10.1.5 in /docs (#33491)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 11:28:10 +07:00
dependabot[bot]
0f240ea1b2 chore(deps-dev): bump webpack from 5.99.7 to 5.99.8 in /docs (#33492)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 11:27:20 +07:00
dependabot[bot]
e520538af6 chore(deps): bump antd from 5.24.9 to 5.25.1 in /docs (#33490)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 11:26:50 +07:00
dependabot[bot]
e03d840d06 chore(deps-dev): bump @babel/preset-env from 7.26.7 to 7.27.2 in /superset-frontend (#33499)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 11:26:00 +07:00
Evan Rusackas
1921ba993e fix(dependabot): adds required schedule to uv updates (#33475) 2025-05-16 12:22:11 -07:00
Elizabeth Thompson
b050897ebd fix: allow metadata to parse json (#33444) 2025-05-16 11:27:16 -07:00
Lukas Biermann
0bdd8a223d docs: added europace to INTHEWILD.md (#33458) 2025-05-16 10:11:37 -04:00
Sam Firke
d12f86363f docs(installation): show example of extending Docker image (#33472) 2025-05-16 09:34:25 -04:00
Geido
9f680a63f8 fix(NativeFilters): Apply existing values (#33467) 2025-05-16 13:55:31 +02:00
dependabot[bot]
928a052440 chore(deps): bump express from 4.21.2 to 5.1.0 in /superset-websocket/utils/client-ws-app (#32948)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 17:24:17 -06:00
github-actions[bot]
fbc84a1f9a chore(🦾): bump python shillelagh subpackage(s) (#33278)
Co-authored-by: GitHub Action <action@github.com>
2025-05-14 17:18:44 -06:00
Vladislav Korenkov
fa1693dc5f feat(Pie Chart): threshold for Other (#33348) 2025-05-14 12:20:30 -06:00
sha174n
8a8fb49617 docs: CVEs fixed on 4.1.2 (#33435) 2025-05-14 11:36:58 -06:00
JUST.in DO IT
dc4474889d fix(table-chart): time shift is not working (#33425) 2025-05-14 14:19:21 -03:00
Syed Bariman Jan
29ac507d56 fix(deckgl): fix deckgl multiple layers chart filter and viewport (#33364) 2025-05-13 23:03:14 -07:00
Maxime Beauchemin
7f14e434c8 fix: loading examples in CI returns http error "too many requests" (#33412) 2025-05-13 08:36:12 -07:00
Mehmet Salih Yavuz
21ca26acd7 fix(Row): don't unload charts while embedded to reduce rerenders (#33422) 2025-05-13 15:32:39 +02:00
Damian Pendrak
33e48146b0 chore: Add missing ECharts tags (#33397) 2025-05-12 18:10:04 +02:00
irodriguez-nebustream
73701b7295 fix(embedded): handle SUPERSET_APP_ROOT in embedded dashboard URLs (#33356)
Co-authored-by: Irving Rodriguez <irodriguez@Mac.lan>
2025-05-09 15:25:40 -07:00
amaannawab923
22475e787e feat(Table Chart): Row limit Increase , Backend Sorting , Backend Search , Excel/CSV Improvements (#33357)
Co-authored-by: Amaan Nawab <nelsondrew07@gmail.com>
2025-05-09 11:27:31 -06:00
VED PRAKASH KASHYAP
9e38a0cc29 docs: fix for role sync issues in case of custom OAuth2 configuration (#30878) 2025-05-09 11:12:23 -06:00
Rafael Benitez
a391ebecca feat: Run SQL on DataSourceEditor implementation (#33340) 2025-05-09 17:35:59 +02:00
Vitor Avila
72cd9dffa3 fix: Persist catalog change during dataset update + validation fixes (#33384) 2025-05-08 15:22:25 -03:00
Đỗ Trọng Hải
4ed05f4ff1 fix(be/utils): sync cache timeout for memoized function (#31917)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2025-05-07 15:45:15 -06:00
Shao Yu-Lung (Allen)
871cfe0c78 fix(i18n): zh_TW pybabel compile error: placeholders are incompatible (#33345) 2025-05-07 15:18:05 -06:00
Fardin Mustaque
a928f8cd9e feat: add metric name for big number chart types #33013 (#33099)
Co-authored-by: Fardin Mustaque <fardinmustaque@Fardins-Mac-mini.local>
2025-05-07 16:56:02 +02:00
dependabot[bot]
afaaf64f52 chore(deps): bump antd from 5.24.5 to 5.24.9 in /docs (#33319)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-07 21:14:46 +07:00
150 changed files with 4996 additions and 2062 deletions

View File

@@ -27,6 +27,8 @@ updates:
- package-ecosystem: "uv" - package-ecosystem: "uv"
directory: "requirements/" directory: "requirements/"
open-pull-requests-limit: 10 open-pull-requests-limit: 10
schedule:
interval: "weekly"
labels: labels:
- uv - uv
- dependabot - dependabot

View File

@@ -48,6 +48,8 @@ jobs:
allow-dependencies-licenses: pkg:npm/store2@2.14.2, pkg:npm/applitools/core, pkg:npm/applitools/core-base, pkg:npm/applitools/css-tree, pkg:npm/applitools/ec-client, pkg:npm/applitools/eg-socks5-proxy-server, pkg:npm/applitools/eyes, pkg:npm/applitools/eyes-cypress, pkg:npm/applitools/nml-client, pkg:npm/applitools/tunnel-client, pkg:npm/applitools/utils, pkg:npm/node-forge@1.3.1, pkg:npm/rgbcolor, pkg:npm/jszip@3.10.1 allow-dependencies-licenses: pkg:npm/store2@2.14.2, pkg:npm/applitools/core, pkg:npm/applitools/core-base, pkg:npm/applitools/css-tree, pkg:npm/applitools/ec-client, pkg:npm/applitools/eg-socks5-proxy-server, pkg:npm/applitools/eyes, pkg:npm/applitools/eyes-cypress, pkg:npm/applitools/nml-client, pkg:npm/applitools/tunnel-client, pkg:npm/applitools/utils, pkg:npm/node-forge@1.3.1, pkg:npm/rgbcolor, pkg:npm/jszip@3.10.1
python-dependency-liccheck: python-dependency-liccheck:
# NOTE: Configuration for liccheck lives in our pyproject.yml.
# You cannot use a liccheck.ini file in this workflow.
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: "Checkout Repository" - name: "Checkout Repository"

View File

@@ -43,6 +43,7 @@ Join our growing community!
- [Cape Crypto](https://capecrypto.com) - [Cape Crypto](https://capecrypto.com)
- [Capital Service S.A.](https://capitalservice.pl) [@pkonarzewski] - [Capital Service S.A.](https://capitalservice.pl) [@pkonarzewski]
- [Clark.de](https://clark.de/) - [Clark.de](https://clark.de/)
- [Europace](https://europace.de)
- [KarrotPay](https://www.daangnpay.com/) - [KarrotPay](https://www.daangnpay.com/)
- [Remita](https://remita.net) [@mujibishola] - [Remita](https://remita.net) [@mujibishola]
- [Taveo](https://www.taveo.com) [@codek] - [Taveo](https://www.taveo.com) [@codek]

View File

@@ -302,6 +302,15 @@ AUTH_USER_REGISTRATION = True
AUTH_USER_REGISTRATION_ROLE = "Public" AUTH_USER_REGISTRATION_ROLE = "Public"
``` ```
In case you want to assign the `Admin` role on new user registration, it can be assigned as follows:
```python
AUTH_USER_REGISTRATION_ROLE = "Admin"
```
If you encounter the [issue](https://github.com/apache/superset/issues/13243) of not being able to list users from the Superset main page settings, although a newly registered user has an `Admin` role, please re-run `superset init` to sync the required permissions. Below is the command to re-run `superset init` using docker compose.
```
docker-compose exec superset superset init
```
Then, create a `CustomSsoSecurityManager` that extends `SupersetSecurityManager` and overrides Then, create a `CustomSsoSecurityManager` that extends `SupersetSecurityManager` and overrides
`oauth_user_info`: `oauth_user_info`:

View File

@@ -64,6 +64,51 @@ check the [supersetbot docker](https://github.com/apache-superset/supersetbot)
subcommand and the [docker.yml](https://github.com/apache/superset/blob/master/.github/workflows/docker.yml) subcommand and the [docker.yml](https://github.com/apache/superset/blob/master/.github/workflows/docker.yml)
GitHub action. GitHub action.
## Building your own production Docker image
Every Superset deployment will require its own set of drivers depending on the data warehouse(s),
etc. so we recommend that users build their own Docker image by extending the `lean` image.
Here's an example Dockerfile that does this. Follow the in-line comments to customize it for
your desired Superset version and database drivers. The comments also note that a certain feature flag will
have to be enabled in your config file.
You would build the image with `docker build -t mysuperset:latest .` or `docker build -t ourcompanysuperset:4.1.2 .`
```Dockerfile
# change this to apache/superset:4.1.2 or whatever version you want to build from;
# otherwise the default is the latest commit on GitHub master branch
FROM apache/superset:master
USER root
# Set environment variable for Playwright
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/playwright-browsers
# Install packages using uv into the virtual environment
# Superset started using uv after the 4.1 branch; if you are building from apache/superset:4.1.x,
# replace the first two lines with RUN pip install \
RUN . /app/.venv/bin/activate && \
uv pip install \
# install psycopg2 for using PostgreSQL metadata store - could be a MySQL package if using that backend:
psycopg2-binary \
# add the driver(s) for your data warehouse(s), in this example we're showing for Microsoft SQL Server:
pymssql \
# package needed for using single-sign on authentication:
Authlib \
# install Playwright for taking screenshots for Alerts & Reports. This assumes the feature flag PLAYWRIGHT_REPORTS_AND_THUMBNAILS is enabled
# That feature flag will default to True starting in 6.0.0
# Playwright works only with Chrome.
# If you are still using Selenium instead of Playwright, you would instead install here the selenium package and a headless browser & webdriver
playwright \
&& PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/playwright-browsers playwright install chromium
# Switch back to the superset user
USER superset
CMD ["/app/docker/entrypoints/run-server.sh"]
```
## Key ARGs in Dockerfile ## Key ARGs in Dockerfile
- `BUILD_TRANSLATIONS`: whether to build the translations into the image. For the - `BUILD_TRANSLATIONS`: whether to build the translations into the image. For the

View File

@@ -27,9 +27,7 @@ You will need to back up your metadata DB. That could mean backing up the servic
You will also need to extend the Superset docker image. The default `lean` images do not contain drivers needed to access your metadata database (Postgres or MySQL), nor to access your data warehouse, nor the headless browser needed for Alerts & Reports. You could run a `-dev` image while demoing Superset, which has some of this, but you'll still need to install the driver for your data warehouse. The `-dev` images run as root, which is not recommended for production. You will also need to extend the Superset docker image. The default `lean` images do not contain drivers needed to access your metadata database (Postgres or MySQL), nor to access your data warehouse, nor the headless browser needed for Alerts & Reports. You could run a `-dev` image while demoing Superset, which has some of this, but you'll still need to install the driver for your data warehouse. The `-dev` images run as root, which is not recommended for production.
Ideally you will build your own image of Superset that extends `lean`, adding what your deployment needs. Ideally you will build your own image of Superset that extends `lean`, adding what your deployment needs. See [Building your own production Docker image](/docs/installation/docker-builds/#building-your-own-production-docker-image).
See [Docker Build Presets](/docs/installation/docker-builds/#build-presets) for more information about the different image versions you can extend.
## [Kubernetes (K8s)](/docs/installation/kubernetes.mdx) ## [Kubernetes (K8s)](/docs/installation/kubernetes.mdx)

View File

@@ -2,6 +2,12 @@
title: CVEs fixed by release title: CVEs fixed by release
sidebar_position: 2 sidebar_position: 2
--- ---
#### Version 4.1.2
| CVE | Title | Affected |
|:---------------|:-----------------------------------------------------------------------------------|---------:|
| CVE-2025-27696 | Improper authorization leading to resource ownership takeover | < 4.1.2 |
#### Version 4.1.0 #### Version 4.1.0
| CVE | Title | Affected | | CVE | Title | Affected |

View File

@@ -26,7 +26,7 @@
"@emotion/styled": "^10.0.27", "@emotion/styled": "^10.0.27",
"@saucelabs/theme-github-codeblock": "^0.3.0", "@saucelabs/theme-github-codeblock": "^0.3.0",
"@superset-ui/style": "^0.14.23", "@superset-ui/style": "^0.14.23",
"antd": "^5.24.5", "antd": "^5.25.1",
"docusaurus-plugin-less": "^2.0.2", "docusaurus-plugin-less": "^2.0.2",
"less": "^4.3.0", "less": "^4.3.0",
"less-loader": "^11.0.0", "less-loader": "^11.0.0",
@@ -44,12 +44,12 @@
"@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0", "@typescript-eslint/parser": "^5.0.0",
"eslint": "^8.0.0", "eslint": "^8.0.0",
"eslint-config-prettier": "^10.1.2", "eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"prettier": "^2.0.0", "prettier": "^2.0.0",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"webpack": "^5.99.7" "webpack": "^5.99.8"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [

View File

@@ -1092,20 +1092,13 @@
core-js-pure "^3.30.2" core-js-pure "^3.30.2"
regenerator-runtime "^0.14.0" regenerator-runtime "^0.14.0"
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.25.9", "@babel/runtime@^7.8.4": "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.1", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.4", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.7", "@babel/runtime@^7.18.0", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.22.5", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.6", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.4", "@babel/runtime@^7.24.7", "@babel/runtime@^7.24.8", "@babel/runtime@^7.25.7", "@babel/runtime@^7.25.9", "@babel/runtime@^7.26.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4":
version "7.27.0" version "7.27.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.0.tgz#fbee7cf97c709518ecc1f590984481d5460d4762" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.0.tgz#fbee7cf97c709518ecc1f590984481d5460d4762"
integrity sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw== integrity sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==
dependencies: dependencies:
regenerator-runtime "^0.14.0" regenerator-runtime "^0.14.0"
"@babel/runtime@^7.10.1", "@babel/runtime@^7.10.4", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.16.7", "@babel/runtime@^7.18.0", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.22.5", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.6", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.4", "@babel/runtime@^7.24.7", "@babel/runtime@^7.24.8", "@babel/runtime@^7.25.7", "@babel/runtime@^7.26.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2":
version "7.26.10"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.10.tgz#a07b4d8fa27af131a633d7b3524db803eb4764c2"
integrity sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/template@^7.25.9", "@babel/template@^7.26.9", "@babel/template@^7.27.0": "@babel/template@^7.25.9", "@babel/template@^7.26.9", "@babel/template@^7.27.0":
version "7.27.0" version "7.27.0"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.0.tgz#b253e5406cc1df1c57dcd18f11760c2dbf40c0b4" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.0.tgz#b253e5406cc1df1c57dcd18f11760c2dbf40c0b4"
@@ -4186,10 +4179,10 @@ ansi-styles@^6.1.0:
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
antd@^5.24.5: antd@^5.25.1:
version "5.24.5" version "5.25.1"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.24.5.tgz#b0976a113163888d1477f9e666c3c23352b098e9" resolved "https://registry.yarnpkg.com/antd/-/antd-5.25.1.tgz#859b419a18d113492304ccd66c29074a71902241"
integrity sha512-1lAv/G+9ewQanyoAo3JumQmIlVxwo5QwWGb6QCHYc40Cq0NxC/EzITcjsgq1PSaTUpLkKq8A2l7Fjtu47vqQBg== integrity sha512-4KC7KuPCjr0z3Vuw9DsF+ceqJaPLbuUI3lOX1sY8ix25ceamp+P8yxOmk3Y2JHCD2ZAhq+5IQ/DTJRN2adWYKQ==
dependencies: dependencies:
"@ant-design/colors" "^7.2.0" "@ant-design/colors" "^7.2.0"
"@ant-design/cssinjs" "^1.23.0" "@ant-design/cssinjs" "^1.23.0"
@@ -4206,37 +4199,37 @@ antd@^5.24.5:
classnames "^2.5.1" classnames "^2.5.1"
copy-to-clipboard "^3.3.3" copy-to-clipboard "^3.3.3"
dayjs "^1.11.11" dayjs "^1.11.11"
rc-cascader "~3.33.1" rc-cascader "~3.34.0"
rc-checkbox "~3.5.0" rc-checkbox "~3.5.0"
rc-collapse "~3.9.0" rc-collapse "~3.9.0"
rc-dialog "~9.6.0" rc-dialog "~9.6.0"
rc-drawer "~7.2.0" rc-drawer "~7.2.0"
rc-dropdown "~4.2.1" rc-dropdown "~4.2.1"
rc-field-form "~2.7.0" rc-field-form "~2.7.0"
rc-image "~7.11.1" rc-image "~7.12.0"
rc-input "~1.7.3" rc-input "~1.8.0"
rc-input-number "~9.4.0" rc-input-number "~9.5.0"
rc-mentions "~2.19.1" rc-mentions "~2.20.0"
rc-menu "~9.16.1" rc-menu "~9.16.1"
rc-motion "^2.9.5" rc-motion "^2.9.5"
rc-notification "~5.6.3" rc-notification "~5.6.4"
rc-pagination "~5.1.0" rc-pagination "~5.1.0"
rc-picker "~4.11.3" rc-picker "~4.11.3"
rc-progress "~4.0.0" rc-progress "~4.0.0"
rc-rate "~2.13.1" rc-rate "~2.13.1"
rc-resize-observer "^1.4.3" rc-resize-observer "^1.4.3"
rc-segmented "~2.7.0" rc-segmented "~2.7.0"
rc-select "~14.16.6" rc-select "~14.16.7"
rc-slider "~11.1.8" rc-slider "~11.1.8"
rc-steps "~6.0.1" rc-steps "~6.0.1"
rc-switch "~4.1.0" rc-switch "~4.1.0"
rc-table "~7.50.4" rc-table "~7.50.4"
rc-tabs "~15.5.1" rc-tabs "~15.6.1"
rc-textarea "~1.9.0" rc-textarea "~1.10.0"
rc-tooltip "~6.4.0" rc-tooltip "~6.4.0"
rc-tree "~5.13.1" rc-tree "~5.13.1"
rc-tree-select "~5.27.0" rc-tree-select "~5.27.0"
rc-upload "~4.8.1" rc-upload "~4.9.0"
rc-util "^5.44.4" rc-util "^5.44.4"
scroll-into-view-if-needed "^3.1.0" scroll-into-view-if-needed "^3.1.0"
throttle-debounce "^5.0.2" throttle-debounce "^5.0.2"
@@ -5674,12 +5667,7 @@ data-view-byte-offset@^1.0.1:
es-errors "^1.3.0" es-errors "^1.3.0"
is-data-view "^1.0.1" is-data-view "^1.0.1"
dayjs@^1.11.11: dayjs@^1.11.11, dayjs@^1.11.13:
version "1.11.12"
resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.12.tgz"
integrity sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==
dayjs@^1.11.13:
version "1.11.13" version "1.11.13"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c"
integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==
@@ -6259,10 +6247,10 @@ escape-string-regexp@^5.0.0:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8"
integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==
eslint-config-prettier@^10.1.2: eslint-config-prettier@^10.1.5:
version "10.1.2" version "10.1.5"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.2.tgz#31a4b393c40c4180202c27e829af43323bf85276" resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz#00c18d7225043b6fbce6a665697377998d453782"
integrity sha512-Epgp/EofAUeEpIdZkW60MHKvPyru1ruQJxPL+WIycnaPApuseK0Zpkrh/FwL9oIpQvIhJwV7ptOy0DWUjTlCiA== integrity sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==
eslint-plugin-prettier@^4.0.0: eslint-plugin-prettier@^4.0.0:
version "4.2.1" version "4.2.1"
@@ -10617,10 +10605,10 @@ raw-body@2.5.2:
iconv-lite "0.4.24" iconv-lite "0.4.24"
unpipe "1.0.0" unpipe "1.0.0"
rc-cascader@~3.33.1: rc-cascader@~3.34.0:
version "3.33.1" version "3.34.0"
resolved "https://registry.yarnpkg.com/rc-cascader/-/rc-cascader-3.33.1.tgz#19e01462ef5ef51b723c1f562c7b9cde4691e7ee" resolved "https://registry.yarnpkg.com/rc-cascader/-/rc-cascader-3.34.0.tgz#56f936ab6b1229bab7d558701ce9b9e96536582c"
integrity sha512-Kyl4EJ7ZfCBuidmZVieegcbFw0RcU5bHHSbtEdmuLYd0fYHCAiYKZ6zon7fWAVyC6rWWOOib0XKdTSf7ElC9rg== integrity sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==
dependencies: dependencies:
"@babel/runtime" "^7.25.7" "@babel/runtime" "^7.25.7"
classnames "^2.3.1" classnames "^2.3.1"
@@ -10688,10 +10676,10 @@ rc-field-form@~2.7.0:
"@rc-component/async-validator" "^5.0.3" "@rc-component/async-validator" "^5.0.3"
rc-util "^5.32.2" rc-util "^5.32.2"
rc-image@~7.11.1: rc-image@~7.12.0:
version "7.11.1" version "7.12.0"
resolved "https://registry.yarnpkg.com/rc-image/-/rc-image-7.11.1.tgz#3ab290708dc053d3681de94186522e4e594f6772" resolved "https://registry.yarnpkg.com/rc-image/-/rc-image-7.12.0.tgz#95e9314701e668217d113c1f29b4f01ac025cafe"
integrity sha512-XuoWx4KUXg7hNy5mRTy1i8c8p3K8boWg6UajbHpDXS5AlRVucNfTi5YxTtPBTBzegxAZpvuLfh3emXFt6ybUdA== integrity sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==
dependencies: dependencies:
"@babel/runtime" "^7.11.2" "@babel/runtime" "^7.11.2"
"@rc-component/portal" "^1.0.2" "@rc-component/portal" "^1.0.2"
@@ -10700,37 +10688,37 @@ rc-image@~7.11.1:
rc-motion "^2.6.2" rc-motion "^2.6.2"
rc-util "^5.34.1" rc-util "^5.34.1"
rc-input-number@~9.4.0: rc-input-number@~9.5.0:
version "9.4.0" version "9.5.0"
resolved "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.4.0.tgz" resolved "https://registry.yarnpkg.com/rc-input-number/-/rc-input-number-9.5.0.tgz#b47963d0f2cbd85ab2f1badfdc089a904c073f38"
integrity sha512-Tiy4DcXcFXAf9wDhN8aUAyMeCLHJUHA/VA/t7Hj8ZEx5ETvxG7MArDOSE6psbiSCo+vJPm4E3fGN710ITVn6GA== integrity sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==
dependencies: dependencies:
"@babel/runtime" "^7.10.1" "@babel/runtime" "^7.10.1"
"@rc-component/mini-decimal" "^1.0.1" "@rc-component/mini-decimal" "^1.0.1"
classnames "^2.2.5" classnames "^2.2.5"
rc-input "~1.7.1" rc-input "~1.8.0"
rc-util "^5.40.1" rc-util "^5.40.1"
rc-input@~1.7.1, rc-input@~1.7.3: rc-input@~1.8.0:
version "1.7.3" version "1.8.0"
resolved "https://registry.yarnpkg.com/rc-input/-/rc-input-1.7.3.tgz#cb334a17b93ce985bceb243b4c111a5ed641e0e3" resolved "https://registry.yarnpkg.com/rc-input/-/rc-input-1.8.0.tgz#d2f4404befebf2fbdc28390d5494c302f74ae974"
integrity sha512-A5w4egJq8+4JzlQ55FfQjDnPvOaAbzwC3VLOAdOytyek3TboSOP9qxN+Gifup+shVXfvecBLBbWBpWxmk02SWQ== integrity sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==
dependencies: dependencies:
"@babel/runtime" "^7.11.1" "@babel/runtime" "^7.11.1"
classnames "^2.2.1" classnames "^2.2.1"
rc-util "^5.18.1" rc-util "^5.18.1"
rc-mentions@~2.19.1: rc-mentions@~2.20.0:
version "2.19.1" version "2.20.0"
resolved "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.19.1.tgz" resolved "https://registry.yarnpkg.com/rc-mentions/-/rc-mentions-2.20.0.tgz#3bbeac0352b02e0ce3e1244adb48701bb6903bf7"
integrity sha512-KK3bAc/bPFI993J3necmaMXD2reZTzytZdlTvkeBbp50IGH1BDPDvxLdHDUrpQx2b2TGaVJsn+86BvYa03kGqA== integrity sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==
dependencies: dependencies:
"@babel/runtime" "^7.22.5" "@babel/runtime" "^7.22.5"
"@rc-component/trigger" "^2.0.0" "@rc-component/trigger" "^2.0.0"
classnames "^2.2.6" classnames "^2.2.6"
rc-input "~1.7.1" rc-input "~1.8.0"
rc-menu "~9.16.0" rc-menu "~9.16.0"
rc-textarea "~1.9.0" rc-textarea "~1.10.0"
rc-util "^5.34.1" rc-util "^5.34.1"
rc-menu@~9.16.0, rc-menu@~9.16.1: rc-menu@~9.16.0, rc-menu@~9.16.1:
@@ -10754,10 +10742,10 @@ rc-motion@^2.0.0, rc-motion@^2.0.1, rc-motion@^2.3.0, rc-motion@^2.3.4, rc-motio
classnames "^2.2.1" classnames "^2.2.1"
rc-util "^5.44.0" rc-util "^5.44.0"
rc-notification@~5.6.3: rc-notification@~5.6.4:
version "5.6.3" version "5.6.4"
resolved "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.3.tgz" resolved "https://registry.yarnpkg.com/rc-notification/-/rc-notification-5.6.4.tgz#ea89c39c13cd517fdfd97fe63f03376fabb78544"
integrity sha512-42szwnn8VYQoT6GnjO00i1iwqV9D1TTMvxObWsuLwgl0TsOokzhkYiufdtQBsJMFjJravS1hfDKVMHLKLcPE4g== integrity sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==
dependencies: dependencies:
"@babel/runtime" "^7.10.1" "@babel/runtime" "^7.10.1"
classnames "2.x" classnames "2.x"
@@ -10833,10 +10821,10 @@ rc-segmented@~2.7.0:
rc-motion "^2.4.4" rc-motion "^2.4.4"
rc-util "^5.17.0" rc-util "^5.17.0"
rc-select@~14.16.2, rc-select@~14.16.6: rc-select@~14.16.2, rc-select@~14.16.7:
version "14.16.6" version "14.16.8"
resolved "https://registry.npmjs.org/rc-select/-/rc-select-14.16.6.tgz" resolved "https://registry.yarnpkg.com/rc-select/-/rc-select-14.16.8.tgz#78e6782f1ccc1f03d9003bc3effa4ed609d29a97"
integrity sha512-YPMtRPqfZWOm2XGTbx5/YVr1HT0vn//8QS77At0Gjb3Lv+Lbut0IORJPKLWu1hQ3u4GsA0SrDzs7nI8JG7Zmyg== integrity sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==
dependencies: dependencies:
"@babel/runtime" "^7.10.1" "@babel/runtime" "^7.10.1"
"@rc-component/trigger" "^2.1.1" "@rc-component/trigger" "^2.1.1"
@@ -10885,10 +10873,10 @@ rc-table@~7.50.4:
rc-util "^5.44.3" rc-util "^5.44.3"
rc-virtual-list "^3.14.2" rc-virtual-list "^3.14.2"
rc-tabs@~15.5.1: rc-tabs@~15.6.1:
version "15.5.1" version "15.6.1"
resolved "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.5.1.tgz" resolved "https://registry.yarnpkg.com/rc-tabs/-/rc-tabs-15.6.1.tgz#f0b6c65384dfa09a64eb539e86a0667c7a650708"
integrity sha512-yiWivLAjEo5d1v2xlseB2dQocsOhkoVSfo1krS8v8r+02K+TBUjSjXIf7dgyVSxp6wRIPv5pMi5hanNUlQMgUA== integrity sha512-/HzDV1VqOsUWyuC0c6AkxVYFjvx9+rFPKZ32ejxX0Uc7QCzcEjTA9/xMgv4HemPKwzBNX8KhGVbbumDjnj92aA==
dependencies: dependencies:
"@babel/runtime" "^7.11.2" "@babel/runtime" "^7.11.2"
classnames "2.x" classnames "2.x"
@@ -10898,14 +10886,14 @@ rc-tabs@~15.5.1:
rc-resize-observer "^1.0.0" rc-resize-observer "^1.0.0"
rc-util "^5.34.1" rc-util "^5.34.1"
rc-textarea@~1.9.0: rc-textarea@~1.10.0:
version "1.9.0" version "1.10.0"
resolved "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.9.0.tgz" resolved "https://registry.yarnpkg.com/rc-textarea/-/rc-textarea-1.10.0.tgz#f8f962ef83be0b8e35db97cf03dbfb86ddd9c46c"
integrity sha512-dQW/Bc/MriPBTugj2Kx9PMS5eXCCGn2cxoIaichjbNvOiARlaHdI99j4DTxLl/V8+PIfW06uFy7kjfUIDDKyxQ== integrity sha512-ai9IkanNuyBS4x6sOL8qu/Ld40e6cEs6pgk93R+XLYg0mDSjNBGey6/ZpDs5+gNLD7urQ14po3V6Ck2dJLt9SA==
dependencies: dependencies:
"@babel/runtime" "^7.10.1" "@babel/runtime" "^7.10.1"
classnames "^2.2.1" classnames "^2.2.1"
rc-input "~1.7.1" rc-input "~1.8.0"
rc-resize-observer "^1.0.0" rc-resize-observer "^1.0.0"
rc-util "^5.27.0" rc-util "^5.27.0"
@@ -10941,10 +10929,10 @@ rc-tree@~5.13.0, rc-tree@~5.13.1:
rc-util "^5.16.1" rc-util "^5.16.1"
rc-virtual-list "^3.5.1" rc-virtual-list "^3.5.1"
rc-upload@~4.8.1: rc-upload@~4.9.0:
version "4.8.1" version "4.9.0"
resolved "https://registry.npmjs.org/rc-upload/-/rc-upload-4.8.1.tgz" resolved "https://registry.yarnpkg.com/rc-upload/-/rc-upload-4.9.0.tgz#911963ab5a0b538c743765371c05e2de9e3f5436"
integrity sha512-toEAhwl4hjLAI1u8/CgKWt30BR06ulPa4iGQSMvSXoHzO88gPCslxqV/mnn4gJU7PDoltGIC9Eh+wkeudqgHyw== integrity sha512-pAzlPnyiFn1GCtEybEG2m9nXNzQyWXqWV2xFYCmDxjN9HzyjS5Pz2F+pbNdYw8mMJsixLEKLG0wVy9vOGxJMJA==
dependencies: dependencies:
"@babel/runtime" "^7.18.3" "@babel/runtime" "^7.18.3"
classnames "^2.2.5" classnames "^2.2.5"
@@ -13045,10 +13033,10 @@ webpack-sources@^3.2.3:
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
webpack@^5.88.1, webpack@^5.95.0, webpack@^5.99.7: webpack@^5.88.1, webpack@^5.95.0, webpack@^5.99.8:
version "5.99.7" version "5.99.8"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.99.7.tgz#60201c1ca66da046b07d006c2f6e0cc5e8a7bdba" resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.99.8.tgz#dd31a020b7c092d30c4c6d9a4edb95809e7f5946"
integrity sha512-CNqKBRMQjwcmKR0idID5va1qlhrqVUKpovi+Ec79ksW8ux7iS1+A6VqzfZXgVYCFRKl7XL5ap3ZoMpwBJxcg0w== integrity sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==
dependencies: dependencies:
"@types/eslint-scope" "^3.7.7" "@types/eslint-scope" "^3.7.7"
"@types/estree" "^1.0.6" "@types/estree" "^1.0.6"

View File

@@ -44,7 +44,7 @@ dependencies = [
"cryptography>=42.0.4, <45.0.0", "cryptography>=42.0.4, <45.0.0",
"deprecation>=2.1.0, <2.2.0", "deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <3.0.0", "flask>=2.2.5, <3.0.0",
"flask-appbuilder>=4.6.3, <5.0.0", "flask-appbuilder>=4.6.4, <5.0.0",
"flask-caching>=2.1.0, <3", "flask-caching>=2.1.0, <3",
"flask-compress>=1.13, <2.0", "flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0", "flask-talisman>=1.0.0, <2.0",
@@ -240,6 +240,12 @@ disallow_untyped_calls = false
disallow_untyped_defs = false disallow_untyped_defs = false
disable_error_code = "annotation-unchecked" disable_error_code = "annotation-unchecked"
# TODO: remove this once cryptography is fixed, introduced in cryptography 44.0.3
[[tool.mypy.overrides]]
module = "cryptography.*"
ignore_errors = true
follow_imports = "skip"
[tool.ruff] [tool.ruff]
# Exclude a variety of commonly ignored directories. # Exclude a variety of commonly ignored directories.
exclude = [ exclude = [
@@ -272,7 +278,6 @@ exclude = [
"venv", "venv",
] ]
# Same as Black. # Same as Black.
line-length = 88 line-length = 88
indent-width = 4 indent-width = 4
@@ -367,6 +372,7 @@ docstring-code-line-length = "dynamic"
requirement_txt_file = "requirements/base.txt" requirement_txt_file = "requirements/base.txt"
authorized_licenses = [ authorized_licenses = [
"academic free license (afl)", "academic free license (afl)",
"any-osi",
"apache license 2.0", "apache license 2.0",
"apache software", "apache software",
"apache software, bsd", "apache software, bsd",
@@ -380,6 +386,7 @@ authorized_licenses = [
"osi approved", "osi approved",
"psf-2.0", "psf-2.0",
"python software foundation", "python software foundation",
"simplified bsd",
"the unlicense (unlicense)", "the unlicense (unlicense)",
"the unlicense", "the unlicense",
] ]

View File

@@ -1,6 +1,6 @@
# This file was autogenerated by uv via the following command: # This file was autogenerated by uv via the following command:
# uv pip compile pyproject.toml requirements/base.in -o requirements/base.txt # uv pip compile pyproject.toml requirements/base.in -o requirements/base.txt
alembic==1.15.1 alembic==1.15.2
# via flask-migrate # via flask-migrate
amqp==5.3.1 amqp==5.3.1
# via kombu # via kombu
@@ -8,7 +8,7 @@ apispec==6.6.1
# via # via
# -r requirements/base.in # -r requirements/base.in
# flask-appbuilder # flask-appbuilder
apsw==3.49.1.0 apsw==3.49.2.0
# via shillelagh # via shillelagh
async-timeout==4.0.3 async-timeout==4.0.3
# via # via
@@ -32,7 +32,7 @@ billiard==4.2.1
# via celery # via celery
blinker==1.9.0 blinker==1.9.0
# via flask # via flask
bottleneck==1.4.2 bottleneck==1.5.0
# via apache-superset (pyproject.toml) # via apache-superset (pyproject.toml)
brotli==1.1.0 brotli==1.1.0
# via flask-compress # via flask-compress
@@ -42,11 +42,11 @@ cachelib==0.13.0
# flask-session # flask-session
cachetools==5.5.2 cachetools==5.5.2
# via google-auth # via google-auth
cattrs==24.1.2 cattrs==24.1.3
# via requests-cache # via requests-cache
celery==5.5.2 celery==5.5.2
# via apache-superset (pyproject.toml) # via apache-superset (pyproject.toml)
certifi==2025.1.31 certifi==2025.4.26
# via # via
# requests # requests
# selenium # selenium
@@ -54,9 +54,9 @@ cffi==1.17.1
# via # via
# cryptography # cryptography
# pynacl # pynacl
charset-normalizer==3.4.1 charset-normalizer==3.4.2
# via requests # via requests
click==8.1.8 click==8.2.0
# via # via
# apache-superset (pyproject.toml) # apache-superset (pyproject.toml)
# celery # celery
@@ -99,7 +99,7 @@ email-validator==2.2.0
# via flask-appbuilder # via flask-appbuilder
et-xmlfile==2.0.0 et-xmlfile==2.0.0
# via openpyxl # via openpyxl
exceptiongroup==1.2.2 exceptiongroup==1.3.0
# via # via
# cattrs # cattrs
# trio # trio
@@ -118,7 +118,7 @@ flask==2.3.3
# flask-session # flask-session
# flask-sqlalchemy # flask-sqlalchemy
# flask-wtf # flask-wtf
flask-appbuilder==4.6.3 flask-appbuilder==4.6.4
# via apache-superset (pyproject.toml) # via apache-superset (pyproject.toml)
flask-babel==2.0.0 flask-babel==2.0.0
# via flask-appbuilder # via flask-appbuilder
@@ -152,13 +152,12 @@ geographiclib==2.0
# via geopy # via geopy
geopy==2.4.1 geopy==2.4.1
# via apache-superset (pyproject.toml) # via apache-superset (pyproject.toml)
google-auth==2.38.0 google-auth==2.40.1
# via shillelagh # via shillelagh
greenlet==3.1.1 greenlet==3.1.1
# via # via
# apache-superset (pyproject.toml) # apache-superset (pyproject.toml)
# shillelagh # shillelagh
# sqlalchemy
gunicorn==23.0.0 gunicorn==23.0.0
# via apache-superset (pyproject.toml) # via apache-superset (pyproject.toml)
h11==0.16.0 h11==0.16.0
@@ -174,6 +173,7 @@ idna==3.10
# email-validator # email-validator
# requests # requests
# trio # trio
# url-normalize
importlib-metadata==8.7.0 importlib-metadata==8.7.0
# via apache-superset (pyproject.toml) # via apache-superset (pyproject.toml)
isodate==0.7.2 isodate==0.7.2
@@ -190,7 +190,7 @@ jsonpath-ng==1.7.0
# via apache-superset (pyproject.toml) # via apache-superset (pyproject.toml)
jsonschema==4.23.0 jsonschema==4.23.0
# via flask-appbuilder # via flask-appbuilder
jsonschema-specifications==2024.10.1 jsonschema-specifications==2025.4.1
# via jsonschema # via jsonschema
kombu==5.5.3 kombu==5.5.3
# via celery # via celery
@@ -243,7 +243,9 @@ openpyxl==3.1.5
ordered-set==4.1.0 ordered-set==4.1.0
# via flask-limiter # via flask-limiter
outcome==1.3.0.post0 outcome==1.3.0.post0
# via trio # via
# trio
# trio-websocket
packaging==25.0 packaging==25.0
# via # via
# apache-superset (pyproject.toml) # apache-superset (pyproject.toml)
@@ -263,7 +265,7 @@ parsedatetime==2.6
# via apache-superset (pyproject.toml) # via apache-superset (pyproject.toml)
pgsanity==0.2.9 pgsanity==0.2.9
# via apache-superset (pyproject.toml) # via apache-superset (pyproject.toml)
platformdirs==4.3.7 platformdirs==4.3.8
# via requests-cache # via requests-cache
ply==3.11 ply==3.11
# via jsonpath-ng # via jsonpath-ng
@@ -279,7 +281,7 @@ pyasn1==0.6.1
# via # via
# pyasn1-modules # pyasn1-modules
# rsa # rsa
pyasn1-modules==0.4.1 pyasn1-modules==0.4.2
# via google-auth # via google-auth
pycparser==2.22 pycparser==2.22
# via cffi # via cffi
@@ -336,13 +338,13 @@ requests-cache==1.2.1
# via shillelagh # via shillelagh
rich==13.9.4 rich==13.9.4
# via flask-limiter # via flask-limiter
rpds-py==0.23.1 rpds-py==0.25.0
# via # via
# jsonschema # jsonschema
# referencing # referencing
rsa==4.9 rsa==4.9.1
# via google-auth # via google-auth
selenium==4.27.1 selenium==4.32.0
# via apache-superset (pyproject.toml) # via apache-superset (pyproject.toml)
shillelagh==1.3.5 shillelagh==1.3.5
# via apache-superset (pyproject.toml) # via apache-superset (pyproject.toml)
@@ -352,7 +354,6 @@ six==1.17.0
# via # via
# prison # prison
# python-dateutil # python-dateutil
# url-normalize
# wtforms-json # wtforms-json
slack-sdk==3.35.0 slack-sdk==3.35.0
# via apache-superset (pyproject.toml) # via apache-superset (pyproject.toml)
@@ -373,7 +374,7 @@ sqlalchemy-utils==0.38.3
# via # via
# apache-superset (pyproject.toml) # apache-superset (pyproject.toml)
# flask-appbuilder # flask-appbuilder
sqlglot==26.16.4 sqlglot==26.17.1
# via apache-superset (pyproject.toml) # via apache-superset (pyproject.toml)
sqlparse==0.5.3 sqlparse==0.5.3
# via apache-superset (pyproject.toml) # via apache-superset (pyproject.toml)
@@ -381,17 +382,18 @@ sshtunnel==0.4.0
# via apache-superset (pyproject.toml) # via apache-superset (pyproject.toml)
tabulate==0.8.10 tabulate==0.8.10
# via apache-superset (pyproject.toml) # via apache-superset (pyproject.toml)
trio==0.28.0 trio==0.30.0
# via # via
# selenium # selenium
# trio-websocket # trio-websocket
trio-websocket==0.11.1 trio-websocket==0.12.2
# via selenium # via selenium
typing-extensions==4.12.2 typing-extensions==4.13.2
# via # via
# apache-superset (pyproject.toml) # apache-superset (pyproject.toml)
# alembic # alembic
# cattrs # cattrs
# exceptiongroup
# limits # limits
# pyopenssl # pyopenssl
# referencing # referencing
@@ -402,7 +404,7 @@ tzdata==2025.2
# via # via
# kombu # kombu
# pandas # pandas
url-normalize==1.4.3 url-normalize==2.2.1
# via requests-cache # via requests-cache
urllib3==1.26.20 urllib3==1.26.20
# via # via

View File

@@ -2,7 +2,7 @@
# uv pip compile requirements/development.in -c requirements/base.txt -o requirements/development.txt # uv pip compile requirements/development.in -c requirements/base.txt -o requirements/development.txt
-e . -e .
# via -r requirements/development.in # via -r requirements/development.in
alembic==1.15.1 alembic==1.15.2
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# flask-migrate # flask-migrate
@@ -14,7 +14,7 @@ apispec==6.6.1
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# flask-appbuilder # flask-appbuilder
apsw==3.49.1.0 apsw==3.49.2.0
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# shillelagh # shillelagh
@@ -51,7 +51,7 @@ blinker==1.9.0
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# flask # flask
bottleneck==1.4.2 bottleneck==1.5.0
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# apache-superset # apache-superset
@@ -68,7 +68,7 @@ cachetools==5.5.2
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# google-auth # google-auth
cattrs==24.1.2 cattrs==24.1.3
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# requests-cache # requests-cache
@@ -76,7 +76,7 @@ celery==5.5.2
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# apache-superset # apache-superset
certifi==2025.1.31 certifi==2025.4.26
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# requests # requests
@@ -88,11 +88,11 @@ cffi==1.17.1
# pynacl # pynacl
cfgv==3.4.0 cfgv==3.4.0
# via pre-commit # via pre-commit
charset-normalizer==3.4.1 charset-normalizer==3.4.2
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# requests # requests
click==8.1.8 click==8.2.0
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# apache-superset # apache-superset
@@ -176,7 +176,7 @@ et-xmlfile==2.0.0
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# openpyxl # openpyxl
exceptiongroup==1.2.2 exceptiongroup==1.3.0
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# cattrs # cattrs
@@ -202,7 +202,7 @@ flask==2.3.3
# flask-sqlalchemy # flask-sqlalchemy
# flask-testing # flask-testing
# flask-wtf # flask-wtf
flask-appbuilder==4.6.3 flask-appbuilder==4.6.4
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# apache-superset # apache-superset
@@ -280,7 +280,7 @@ google-api-core==2.23.0
# google-cloud-core # google-cloud-core
# pandas-gbq # pandas-gbq
# sqlalchemy-bigquery # sqlalchemy-bigquery
google-auth==2.38.0 google-auth==2.40.1
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# google-api-core # google-api-core
@@ -318,7 +318,6 @@ greenlet==3.1.1
# apache-superset # apache-superset
# gevent # gevent
# shillelagh # shillelagh
# sqlalchemy
grpcio==1.71.0 grpcio==1.71.0
# via # via
# apache-superset # apache-superset
@@ -355,6 +354,7 @@ idna==3.10
# email-validator # email-validator
# requests # requests
# trio # trio
# url-normalize
importlib-metadata==8.7.0 importlib-metadata==8.7.0
# via # via
# -c requirements/base.txt # -c requirements/base.txt
@@ -389,7 +389,7 @@ jsonschema==4.23.0
# openapi-spec-validator # openapi-spec-validator
jsonschema-path==0.3.4 jsonschema-path==0.3.4
# via openapi-spec-validator # via openapi-spec-validator
jsonschema-specifications==2024.10.1 jsonschema-specifications==2025.4.1
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# jsonschema # jsonschema
@@ -495,6 +495,7 @@ outcome==1.3.0.post0
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# trio # trio
# trio-websocket
packaging==25.0 packaging==25.0
# via # via
# -c requirements/base.txt # -c requirements/base.txt
@@ -542,7 +543,7 @@ pillow==10.3.0
# via # via
# apache-superset # apache-superset
# matplotlib # matplotlib
platformdirs==4.3.7 platformdirs==4.3.8
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# requests-cache # requests-cache
@@ -598,7 +599,7 @@ pyasn1==0.6.1
# pyasn1-modules # pyasn1-modules
# python-ldap # python-ldap
# rsa # rsa
pyasn1-modules==0.4.1 pyasn1-modules==0.4.2
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# google-auth # google-auth
@@ -731,22 +732,22 @@ rich==13.9.4
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# flask-limiter # flask-limiter
rpds-py==0.23.1 rpds-py==0.25.0
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# jsonschema # jsonschema
# referencing # referencing
rsa==4.9 rsa==4.9.1
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# google-auth # google-auth
ruff==0.8.0 ruff==0.8.0
# via apache-superset # via apache-superset
selenium==4.27.1 selenium==4.32.0
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# apache-superset # apache-superset
setuptools==75.6.0 setuptools==80.7.1
# via # via
# nodeenv # nodeenv
# pandas-gbq # pandas-gbq
@@ -767,7 +768,6 @@ six==1.17.0
# prison # prison
# python-dateutil # python-dateutil
# rfc3339-validator # rfc3339-validator
# url-normalize
# wtforms-json # wtforms-json
slack-sdk==3.35.0 slack-sdk==3.35.0
# via # via
@@ -799,7 +799,7 @@ sqlalchemy-utils==0.38.3
# -c requirements/base.txt # -c requirements/base.txt
# apache-superset # apache-superset
# flask-appbuilder # flask-appbuilder
sqlglot==26.16.4 sqlglot==26.17.1
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# apache-superset # apache-superset
@@ -829,21 +829,22 @@ tqdm==4.67.1
# prophet # prophet
trino==0.330.0 trino==0.330.0
# via apache-superset # via apache-superset
trio==0.28.0 trio==0.30.0
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# selenium # selenium
# trio-websocket # trio-websocket
trio-websocket==0.11.1 trio-websocket==0.12.2
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# selenium # selenium
typing-extensions==4.12.2 typing-extensions==4.13.2
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# alembic # alembic
# apache-superset # apache-superset
# cattrs # cattrs
# exceptiongroup
# limits # limits
# pyopenssl # pyopenssl
# referencing # referencing
@@ -857,7 +858,7 @@ tzdata==2025.2
# pandas # pandas
tzlocal==5.2 tzlocal==5.2
# via trino # via trino
url-normalize==1.4.3 url-normalize==2.2.1
# via # via
# -c requirements/base.txt # -c requirements/base.txt
# requests-cache # requests-cache

View File

@@ -252,4 +252,215 @@ describe('Visualization > Table', () => {
}); });
cy.get('td').contains(/\d*%/); cy.get('td').contains(/\d*%/);
}); });
it('Test row limit with server pagination toggle', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: ['count'],
row_limit: 100,
});
// Enable server pagination
cy.get('[data-test="server_pagination-header"] div.pull-left').click();
// Click row limit control and select high value (200k)
cy.get('div[aria-label="Row limit"]').click();
// Type 200000 and press enter to select the option
cy.get('div[aria-label="Row limit"]')
.find('.ant-select-selection-search-input:visible')
.type('200000{enter}');
// Verify that there is no error tooltip when server pagination is enabled
cy.get('[data-test="error-tooltip"]').should('not.exist');
// Disable server pagination
cy.get('[data-test="server_pagination-header"] div.pull-left').click();
// Verify error tooltip appears
cy.get('[data-test="error-tooltip"]').should('be.visible');
// Trigger mouseover and verify tooltip text
cy.get('[data-test="error-tooltip"]').trigger('mouseover');
// Verify tooltip content
cy.get('.antd5-tooltip-inner').should('be.visible');
cy.get('.antd5-tooltip-inner').should(
'contain',
'Server pagination needs to be enabled for values over',
);
// Hide the tooltip by adding display:none style
cy.get('.antd5-tooltip').invoke('attr', 'style', 'display: none');
// Enable server pagination again
cy.get('[data-test="server_pagination-header"] div.pull-left').click();
cy.get('[data-test="error-tooltip"]').should('not.exist');
cy.get('div[aria-label="Row limit"]').click();
// Type 1000000
cy.get('div[aria-label="Row limit"]')
.find('.ant-select-selection-search-input:visible')
.type('1000000');
// Wait for 1 second
cy.wait(1000);
// Press enter
cy.get('div[aria-label="Row limit"]')
.find('.ant-select-selection-search-input:visible')
.type('{enter}');
// Wait for error tooltip to appear and verify its content
cy.get('[data-test="error-tooltip"]')
.should('be.visible')
.trigger('mouseover');
// Wait for tooltip content and verify
cy.get('.antd5-tooltip-inner').should('exist');
cy.get('.antd5-tooltip-inner').should('be.visible');
// Verify tooltip content separately
cy.get('.antd5-tooltip-inner').should('contain', 'Value cannot exceed');
});
it('Test sorting with server pagination enabled', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: ['count'],
groupby: ['name'],
row_limit: 100000,
server_pagination: true, // Enable server pagination
});
// Wait for the initial data load
cy.wait('@chartData');
// Get the first column header (name)
cy.get('.chart-container th').contains('name').as('nameHeader');
// Click to sort ascending
cy.get('@nameHeader').click();
cy.wait('@chartData');
// Verify first row starts with 'A'
cy.get('.chart-container td:first').invoke('text').should('match', /^[Aa]/);
// Click again to sort descending
cy.get('@nameHeader').click();
cy.wait('@chartData');
// Verify first row starts with 'Z'
cy.get('.chart-container td:first').invoke('text').should('match', /^[Zz]/);
// Test numeric sorting
cy.get('.chart-container th').contains('COUNT').as('countHeader');
// Click to sort ascending by count
cy.get('@countHeader').click();
cy.wait('@chartData');
// Get first two count values and verify ascending order
cy.get('.chart-container td:nth-child(2)').then($cells => {
const first = parseFloat($cells[0].textContent || '0');
const second = parseFloat($cells[1].textContent || '0');
expect(first).to.be.at.most(second);
});
// Click again to sort descending
cy.get('@countHeader').click();
cy.wait('@chartData');
// Get first two count values and verify descending order
cy.get('.chart-container td:nth-child(2)').then($cells => {
const first = parseFloat($cells[0].textContent || '0');
const second = parseFloat($cells[1].textContent || '0');
expect(first).to.be.at.least(second);
});
});
it('Test search with server pagination enabled', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: ['count'],
groupby: ['name', 'state'],
row_limit: 100000,
server_pagination: true,
include_search: true,
});
cy.wait('@chartData');
// Basic search test
cy.get('span.dt-global-filter input.form-control.input-sm').should(
'be.visible',
);
cy.get('span.dt-global-filter input.form-control.input-sm').type('John');
cy.wait('@chartData');
cy.get('.chart-container tbody tr').each($row => {
cy.wrap($row).contains(/John/i);
});
// Clear and test case-insensitive search
cy.get('span.dt-global-filter input.form-control.input-sm').clear();
cy.wait('@chartData');
cy.get('span.dt-global-filter input.form-control.input-sm').type('mary');
cy.wait('@chartData');
cy.get('.chart-container tbody tr').each($row => {
cy.wrap($row).contains(/Mary/i);
});
// Test special characters
cy.get('span.dt-global-filter input.form-control.input-sm').clear();
cy.get('span.dt-global-filter input.form-control.input-sm').type('Nicole');
cy.wait('@chartData');
cy.get('.chart-container tbody tr').each($row => {
cy.wrap($row).contains(/Nicole/i);
});
// Test no results
cy.get('span.dt-global-filter input.form-control.input-sm').clear();
cy.get('span.dt-global-filter input.form-control.input-sm').type('XYZ123');
cy.wait('@chartData');
cy.get('.chart-container').contains('No records found');
// Test column-specific search
cy.get('.search-select').should('be.visible');
cy.get('.search-select').click();
cy.get('.ant-select-dropdown').should('be.visible');
cy.get('.ant-select-item-option').contains('state').should('be.visible');
cy.get('.ant-select-item-option').contains('state').click();
cy.get('span.dt-global-filter input.form-control.input-sm').clear();
cy.get('span.dt-global-filter input.form-control.input-sm').type('CA');
cy.wait('@chartData');
cy.wait(1000);
cy.get('td[aria-labelledby="header-state"]').should('be.visible');
cy.get('td[aria-labelledby="header-state"]')
.first()
.should('contain', 'CA');
});
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -122,7 +122,7 @@
"@visx/tooltip": "^3.0.0", "@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1", "@visx/xychart": "^3.5.1",
"abortcontroller-polyfill": "^1.7.8", "abortcontroller-polyfill": "^1.7.8",
"ace-builds": "^1.36.3", "ace-builds": "^1.41.0",
"ag-grid-community": "33.1.1", "ag-grid-community": "33.1.1",
"ag-grid-react": "33.1.1", "ag-grid-react": "33.1.1",
"antd": "4.10.3", "antd": "4.10.3",
@@ -137,6 +137,7 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dom-to-image-more": "^3.2.0", "dom-to-image-more": "^3.2.0",
"dom-to-pdf": "^0.3.2", "dom-to-pdf": "^0.3.2",
"dompurify": "^3.2.4",
"echarts": "^5.6.0", "echarts": "^5.6.0",
"emotion-rgba": "0.0.12", "emotion-rgba": "0.0.12",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings", "eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
@@ -230,7 +231,7 @@
"@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-modules-commonjs": "^7.26.3", "@babel/plugin-transform-modules-commonjs": "^7.26.3",
"@babel/plugin-transform-runtime": "^7.27.1", "@babel/plugin-transform-runtime": "^7.27.1",
"@babel/preset-env": "^7.26.7", "@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.26.3", "@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.26.0", "@babel/preset-typescript": "^7.26.0",
"@babel/register": "^7.23.7", "@babel/register": "^7.23.7",

View File

@@ -26,6 +26,7 @@ import {
import { ColumnMeta, SortSeriesData, SortSeriesType } from './types'; import { ColumnMeta, SortSeriesData, SortSeriesType } from './types';
export const DEFAULT_MAX_ROW = 100000; export const DEFAULT_MAX_ROW = 100000;
export const DEFAULT_MAX_ROW_TABLE_SERVER = 500000;
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
export const TIME_FILTER_LABELS = { export const TIME_FILTER_LABELS = {

View File

@@ -49,6 +49,7 @@ export interface ChartMetadataConfig {
label?: ChartLabel | null; label?: ChartLabel | null;
labelExplanation?: string | null; labelExplanation?: string | null;
queryObjectCount?: number; queryObjectCount?: number;
dynamicQueryObjectCount?: boolean;
parseMethod?: ParseMethod; parseMethod?: ParseMethod;
// suppressContextMenu: true hides the default context menu for the chart. // suppressContextMenu: true hides the default context menu for the chart.
// This is useful for viz plugins that define their own context menu. // This is useful for viz plugins that define their own context menu.
@@ -92,6 +93,8 @@ export default class ChartMetadata {
queryObjectCount: number; queryObjectCount: number;
dynamicQueryObjectCount: boolean;
parseMethod: ParseMethod; parseMethod: ParseMethod;
suppressContextMenu?: boolean; suppressContextMenu?: boolean;
@@ -115,6 +118,7 @@ export default class ChartMetadata {
label = null, label = null,
labelExplanation = null, labelExplanation = null,
queryObjectCount = 1, queryObjectCount = 1,
dynamicQueryObjectCount = false,
parseMethod = 'json-bigint', parseMethod = 'json-bigint',
suppressContextMenu = false, suppressContextMenu = false,
} = config; } = config;
@@ -145,6 +149,7 @@ export default class ChartMetadata {
this.label = label; this.label = label;
this.labelExplanation = labelExplanation; this.labelExplanation = labelExplanation;
this.queryObjectCount = queryObjectCount; this.queryObjectCount = queryObjectCount;
this.dynamicQueryObjectCount = dynamicQueryObjectCount;
this.parseMethod = parseMethod; this.parseMethod = parseMethod;
this.suppressContextMenu = suppressContextMenu; this.suppressContextMenu = suppressContextMenu;
} }

View File

@@ -58,17 +58,18 @@ export default async function parseResponse<T extends ParseMethod = 'json'>(
const result: JsonResponse = { const result: JsonResponse = {
response, response,
json: cloneDeepWith(json, (value: any) => { json: cloneDeepWith(json, (value: any) => {
// `json-bigint` could not handle floats well, see sidorares/json-bigint#62
// TODO: clean up after json-bigint>1.0.1 is released
if (value?.isInteger?.() === false) {
return Number(value);
}
if ( if (
value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) || value?.isInteger?.() === true &&
value?.isLessThan?.(Number.MIN_SAFE_INTEGER) (value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER))
) { ) {
return BigInt(value); return BigInt(value);
} }
// // `json-bigint` could not handle floats well, see sidorares/json-bigint#62
// // TODO: clean up after json-bigint>1.0.1 is released
if (value?.isNaN?.() === false) {
return value?.toNumber?.();
}
return undefined; return undefined;
}), }),
}; };

View File

@@ -25,3 +25,4 @@ export { default as validateNonEmpty } from './validateNonEmpty';
export { default as validateMaxValue } from './validateMaxValue'; export { default as validateMaxValue } from './validateMaxValue';
export { default as validateMapboxStylesUrl } from './validateMapboxStylesUrl'; export { default as validateMapboxStylesUrl } from './validateMapboxStylesUrl';
export { default as validateTimeComparisonRangeValues } from './validateTimeComparisonRangeValues'; export { default as validateTimeComparisonRangeValues } from './validateTimeComparisonRangeValues';
export { default as validateServerPagination } from './validateServerPagination';

View File

@@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '../translation';
export default function validateServerPagination(
v: unknown,
serverPagination: boolean,
maxValueWithoutServerPagination: number,
maxServer: number,
) {
if (
Number(v) > +maxValueWithoutServerPagination &&
Number(v) <= maxServer &&
!serverPagination
) {
return t(
'Server pagination needs to be enabled for values over %s',
maxValueWithoutServerPagination,
);
}
return false;
}

View File

@@ -143,7 +143,7 @@ describe('parseResponse()', () => {
const mockBigIntUrl = '/mock/get/bigInt'; const mockBigIntUrl = '/mock/get/bigInt';
const mockGetBigIntPayload = `{ const mockGetBigIntPayload = `{
"value": 9223372036854775807, "minus": { "value": -483729382918228373892, "str": "something" }, "value": 9223372036854775807, "minus": { "value": -483729382918228373892, "str": "something" },
"number": 1234, "floatValue": { "plus": 0.3452211361231223, "minus": -0.3452211361231223 }, "number": 1234, "floatValue": { "plus": 0.3452211361231223, "minus": -0.3452211361231223, "even": 1234567890123456.0000000 },
"string.constructor": "data.constructor", "string.constructor": "data.constructor",
"constructor": "constructor" "constructor": "constructor"
}`; }`;
@@ -161,6 +161,7 @@ describe('parseResponse()', () => {
expect(responseBigNumber.json.floatValue.minus).toEqual( expect(responseBigNumber.json.floatValue.minus).toEqual(
-0.3452211361231223, -0.3452211361231223,
); );
expect(responseBigNumber.json.floatValue.even).toEqual(1234567890123456);
expect( expect(
responseBigNumber.json.floatValue.plus + responseBigNumber.json.floatValue.plus +
responseBigNumber.json.floatValue.minus, responseBigNumber.json.floatValue.minus,

View File

@@ -0,0 +1,153 @@
/**
* 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 { validateServerPagination } from '@superset-ui/core';
import './setup';
const DEFAULT_MAX_ROW = 100000;
const DEFAULT_MAX_ROW_TABLE_SERVER = 500000;
test('validateServerPagination returns warning message only when value is between max thresholds and server pagination is disabled', () => {
// Should show warning - value between thresholds and server pagination disabled
expect(
validateServerPagination(
200000,
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeTruthy();
expect(
validateServerPagination(
300000,
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeTruthy();
// Should not show warning - value above max server threshold
expect(
validateServerPagination(
600000,
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
// Should not show warning - value below max without server threshold
expect(
validateServerPagination(
50000,
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
});
test('validateServerPagination returns false when server pagination is enabled regardless of value', () => {
expect(
validateServerPagination(
200000,
true,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
expect(
validateServerPagination(
300000,
true,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
expect(
validateServerPagination(
600000,
true,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
});
test('validateServerPagination handles string inputs correctly', () => {
expect(
validateServerPagination(
'200000',
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeTruthy();
expect(
validateServerPagination(
'600000',
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
expect(
validateServerPagination(
'50000',
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
});
test('validateServerPagination handles edge cases', () => {
expect(
validateServerPagination(
undefined,
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
expect(
validateServerPagination(
null,
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
expect(
validateServerPagination(
NaN,
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
expect(
validateServerPagination(
'invalid',
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
});

View File

@@ -54,7 +54,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.26.0", "@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.7", "@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.26.3", "@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.23.3", "@babel/preset-typescript": "^7.23.3",
"@storybook/react-webpack5": "8.2.9", "@storybook/react-webpack5": "8.2.9",

View File

@@ -38,9 +38,20 @@ import {
} from '../DeckGLContainer'; } from '../DeckGLContainer';
import { getExploreLongUrl } from '../utils/explore'; import { getExploreLongUrl } from '../utils/explore';
import layerGenerators from '../layers'; import layerGenerators from '../layers';
import { Viewport } from '../utils/fitViewport'; import fitViewport, { Viewport } from '../utils/fitViewport';
import { TooltipProps } from '../components/Tooltip'; import { TooltipProps } from '../components/Tooltip';
import { getPoints as getPointsArc } from '../layers/Arc/Arc';
import { getPoints as getPointsPath } from '../layers/Path/Path';
import { getPoints as getPointsPolygon } from '../layers/Polygon/Polygon';
import { getPoints as getPointsGrid } from '../layers/Grid/Grid';
import { getPoints as getPointsScatter } from '../layers/Scatter/Scatter';
import { getPoints as getPointsContour } from '../layers/Contour/Contour';
import { getPoints as getPointsHeatmap } from '../layers/Heatmap/Heatmap';
import { getPoints as getPointsHex } from '../layers/Hex/Hex';
import { getPoints as getPointsGeojson } from '../layers/Geojson/Geojson';
import { getPoints as getPointsScreengrid } from '../layers/Screengrid/Screengrid';
export type DeckMultiProps = { export type DeckMultiProps = {
formData: QueryFormData; formData: QueryFormData;
payload: JsonObject; payload: JsonObject;
@@ -56,7 +67,35 @@ export type DeckMultiProps = {
const DeckMulti = (props: DeckMultiProps) => { const DeckMulti = (props: DeckMultiProps) => {
const containerRef = useRef<DeckGLContainerHandle>(); const containerRef = useRef<DeckGLContainerHandle>();
const [viewport, setViewport] = useState<Viewport>(); const getAdjustedViewport = useCallback(() => {
let viewport = { ...props.viewport };
const points = [
...getPointsPolygon(props.payload.data.features.deck_polygon || []),
...getPointsPath(props.payload.data.features.deck_path || []),
...getPointsGrid(props.payload.data.features.deck_grid || []),
...getPointsScatter(props.payload.data.features.deck_scatter || []),
...getPointsContour(props.payload.data.features.deck_contour || []),
...getPointsHeatmap(props.payload.data.features.deck_heatmap || []),
...getPointsHex(props.payload.data.features.deck_hex || []),
...getPointsArc(props.payload.data.features.deck_arc || []),
...getPointsGeojson(props.payload.data.features.deck_geojson || []),
...getPointsScreengrid(props.payload.data.features.deck_screengrid || []),
];
if (props.formData) {
viewport = fitViewport(viewport, {
width: props.width,
height: props.height,
points,
});
}
if (viewport.zoom < 0) {
viewport.zoom = 0;
}
return viewport;
}, [props]);
const [viewport, setViewport] = useState<Viewport>(getAdjustedViewport());
const [subSlicesLayers, setSubSlicesLayers] = useState<Record<number, Layer>>( const [subSlicesLayers, setSubSlicesLayers] = useState<Record<number, Layer>>(
{}, {},
); );
@@ -70,23 +109,31 @@ const DeckMulti = (props: DeckMultiProps) => {
const loadLayers = useCallback( const loadLayers = useCallback(
(formData: QueryFormData, payload: JsonObject, viewport?: Viewport) => { (formData: QueryFormData, payload: JsonObject, viewport?: Viewport) => {
setViewport(viewport); setViewport(getAdjustedViewport());
setSubSlicesLayers({}); setSubSlicesLayers({});
payload.data.slices.forEach( payload.data.slices.forEach(
(subslice: { slice_id: number } & JsonObject) => { (subslice: { slice_id: number } & JsonObject) => {
// Filters applied to multi_deck are passed down to underlying charts // Filters applied to multi_deck are passed down to underlying charts
// note that dashboard contextual information (filter_immune_slices and such) aren't // note that dashboard contextual information (filter_immune_slices and such) aren't
// taken into consideration here // taken into consideration here
const filters = [ const extra_filters = [
...(subslice.form_data.filters || []), ...(subslice.form_data.extra_filters || []),
...(formData.filters || []),
...(formData.extra_filters || []), ...(formData.extra_filters || []),
...(formData.extra_form_data?.filters || []),
]; ];
const adhoc_filters = [
...(formData.adhoc_filters || []),
...(subslice.formData?.adhoc_filters || []),
...(formData.extra_form_data?.adhoc_filters || []),
];
const subsliceCopy = { const subsliceCopy = {
...subslice, ...subslice,
form_data: { form_data: {
...subslice.form_data, ...subslice.form_data,
filters, extra_filters,
adhoc_filters,
}, },
}; };
@@ -117,7 +164,13 @@ const DeckMulti = (props: DeckMultiProps) => {
}, },
); );
}, },
[props.datasource, props.onAddFilter, props.onSelect, setTooltip], [
props.datasource,
props.onAddFilter,
props.onSelect,
setTooltip,
getAdjustedViewport,
],
); );
const prevDeckSlices = usePrevious(props.formData.deck_slices); const prevDeckSlices = usePrevious(props.formData.deck_slices);
@@ -136,7 +189,7 @@ const DeckMulti = (props: DeckMultiProps) => {
<DeckGLContainerStyledWrapper <DeckGLContainerStyledWrapper
ref={containerRef} ref={containerRef}
mapboxApiAccessToken={payload.data.mapboxApiKey} mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport || props.viewport} viewport={viewport}
layers={layers} layers={layers}
mapStyle={formData.mapbox_style} mapStyle={formData.mapbox_style}
setControlValue={setControlValue} setControlValue={setControlValue}

View File

@@ -29,7 +29,7 @@ import TooltipRow from '../../TooltipRow';
import { TooltipProps } from '../../components/Tooltip'; import { TooltipProps } from '../../components/Tooltip';
import { Point } from '../../types'; import { Point } from '../../types';
function getPoints(data: JsonObject[]) { export function getPoints(data: JsonObject[]) {
const points: Point[] = []; const points: Point[] = [];
data.forEach(d => { data.forEach(d => {
points.push(d.sourcePosition); points.push(d.sourcePosition);

View File

@@ -97,7 +97,7 @@ export const getLayer: getLayerType<unknown> = function (
}); });
}; };
function getPoints(data: any[]) { export function getPoints(data: any[]) {
return data.map(d => d.position); return data.map(d => d.position);
} }

View File

@@ -39,6 +39,7 @@ import { commonLayerProps } from '../common';
import TooltipRow from '../../TooltipRow'; import TooltipRow from '../../TooltipRow';
import fitViewport, { Viewport } from '../../utils/fitViewport'; import fitViewport, { Viewport } from '../../utils/fitViewport';
import { TooltipProps } from '../../components/Tooltip'; import { TooltipProps } from '../../components/Tooltip';
import { Point } from '../../types';
type ProcessedFeature = Feature<Geometry, GeoJsonProperties> & { type ProcessedFeature = Feature<Geometry, GeoJsonProperties> & {
properties: JsonObject; properties: JsonObject;
@@ -172,6 +173,17 @@ export type DeckGLGeoJsonProps = {
width: number; width: number;
}; };
export function getPoints(data: Point[]) {
return data.reduce((acc: Array<any>, feature: any) => {
const bounds = geojsonExtent(feature);
if (bounds) {
return [...acc, [bounds[0], bounds[1]], [bounds[2], bounds[3]]];
}
return acc;
}, []);
}
const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => { const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
const containerRef = useRef<DeckGLContainerHandle>(); const containerRef = useRef<DeckGLContainerHandle>();
const setTooltip = useCallback((tooltip: TooltipProps['tooltip']) => { const setTooltip = useCallback((tooltip: TooltipProps['tooltip']) => {
@@ -186,24 +198,13 @@ const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
const viewport: Viewport = useMemo(() => { const viewport: Viewport = useMemo(() => {
if (formData.autozoom) { if (formData.autozoom) {
const points = const points = getPoints(payload.data.features) || [];
payload?.data?.features?.reduce?.(
(acc: [number, number, number, number][], feature: any) => {
const bounds = geojsonExtent(feature);
if (bounds) {
return [...acc, [bounds[0], bounds[1]], [bounds[2], bounds[3]]];
}
return acc;
},
[],
) || [];
if (points.length) { if (points.length) {
return fitViewport(props.viewport, { return fitViewport(props.viewport, {
width, width,
height, height,
points, points: getPoints(payload.data.features) || [],
}); });
} }
} }

View File

@@ -86,7 +86,7 @@ export function getLayer(
}); });
} }
function getPoints(data: JsonObject[]) { export function getPoints(data: JsonObject[]) {
return data.map(d => d.position); return data.map(d => d.position);
} }

View File

@@ -79,7 +79,7 @@ export const getLayer: getLayerType<unknown> = (
}); });
}; };
function getPoints(data: any[]) { export function getPoints(data: any[]) {
return data.map(d => d.position); return data.map(d => d.position);
} }

View File

@@ -84,7 +84,7 @@ export function getLayer(
}); });
} }
function getPoints(data: JsonObject[]) { export function getPoints(data: JsonObject[]) {
return data.map(d => d.position); return data.map(d => d.position);
} }

View File

@@ -76,7 +76,7 @@ export function getLayer(
}); });
} }
function getPoints(data: JsonObject[]) { export function getPoints(data: JsonObject[]) {
let points: Point[] = []; let points: Point[] = [];
data.forEach(d => { data.forEach(d => {
points = points.concat(d.path); points = points.concat(d.path);

View File

@@ -173,6 +173,10 @@ export type DeckGLPolygonProps = {
height: number; height: number;
}; };
export function getPoints(data: JsonObject[]) {
return data.flatMap(getPointsFromPolygon);
}
const DeckGLPolygon = (props: DeckGLPolygonProps) => { const DeckGLPolygon = (props: DeckGLPolygonProps) => {
const containerRef = useRef<DeckGLContainerHandle>(); const containerRef = useRef<DeckGLContainerHandle>();
@@ -183,7 +187,7 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
viewport = fitViewport(viewport, { viewport = fitViewport(viewport, {
width: props.width, width: props.width,
height: props.height, height: props.height,
points: features.flatMap(getPointsFromPolygon), points: getPoints(features),
}); });
} }
if (viewport.zoom < 0) { if (viewport.zoom < 0) {

View File

@@ -30,7 +30,7 @@ import TooltipRow from '../../TooltipRow';
import { unitToRadius } from '../../utils/geo'; import { unitToRadius } from '../../utils/geo';
import { TooltipProps } from '../../components/Tooltip'; import { TooltipProps } from '../../components/Tooltip';
function getPoints(data: JsonObject[]) { export function getPoints(data: JsonObject[]) {
return data.map(d => d.position); return data.map(d => d.position);
} }

View File

@@ -34,7 +34,7 @@ import {
} from '../../DeckGLContainer'; } from '../../DeckGLContainer';
import { TooltipProps } from '../../components/Tooltip'; import { TooltipProps } from '../../components/Tooltip';
function getPoints(data: JsonObject[]) { export function getPoints(data: JsonObject[]) {
return data.map(d => d.position); return data.map(d => d.position);
} }

View File

@@ -31,11 +31,11 @@
"dependencies": { "dependencies": {
"d3": "^3.5.17", "d3": "^3.5.17",
"d3-tip": "^0.9.1", "d3-tip": "^0.9.1",
"dompurify": "^3.2.4",
"fast-safe-stringify": "^2.1.1", "fast-safe-stringify": "^2.1.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"nvd3-fork": "^2.0.5", "nvd3-fork": "^2.0.5",
"dompurify": "^3.2.4",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"urijs": "^1.19.11" "urijs": "^1.19.11"
}, },

View File

@@ -36,13 +36,25 @@ import {
} from './types'; } from './types';
import { useOverflowDetection } from './useOverflowDetection'; import { useOverflowDetection } from './useOverflowDetection';
const MetricNameText = styled.div<{ metricNameFontSize?: number }>`
${({ theme, metricNameFontSize }) => `
font-family: ${theme.typography.families.sansSerif};
font-weight: ${theme.typography.weights.normal};
font-size: ${metricNameFontSize || theme.typography.sizes.s * 2}px;
text-align: center;
margin-bottom: ${theme.gridUnit * 3}px;
`}
`;
const NumbersContainer = styled.div` const NumbersContainer = styled.div`
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
height: 100%;
overflow: auto; overflow: auto;
padding: 12px;
`; `;
const ComparisonValue = styled.div<PopKPIComparisonValueStyleProps>` const ComparisonValue = styled.div<PopKPIComparisonValueStyleProps>`
@@ -73,6 +85,8 @@ export default function PopKPI(props: PopKPIProps) {
prevNumber, prevNumber,
valueDifference, valueDifference,
percentDifferenceFormattedString, percentDifferenceFormattedString,
metricName,
metricNameFontSize,
headerFontSize, headerFontSize,
subheaderFontSize, subheaderFontSize,
comparisonColorEnabled, comparisonColorEnabled,
@@ -84,8 +98,8 @@ export default function PopKPI(props: PopKPIProps) {
subtitle, subtitle,
subtitleFontSize, subtitleFontSize,
dashboardTimeRange, dashboardTimeRange,
showMetricName,
} = props; } = props;
const [comparisonRange, setComparisonRange] = useState<string>(''); const [comparisonRange, setComparisonRange] = useState<string>('');
useEffect(() => { useEffect(() => {
@@ -260,9 +274,16 @@ export default function PopKPI(props: PopKPIProps) {
width: fit-content; width: fit-content;
margin: auto; margin: auto;
align-items: flex-start; align-items: flex-start;
overflow: auto;
` `
} }
> >
{showMetricName && metricName && (
<MetricNameText metricNameFontSize={metricNameFontSize}>
{metricName}
</MetricNameText>
)}
<div css={bigValueContainerStyles}> <div css={bigValueContainerStyles}>
{bigNumber} {bigNumber}
{percentDifferenceNumber !== 0 && ( {percentDifferenceNumber !== 0 && (

View File

@@ -28,6 +28,8 @@ import {
subheaderFontSize, subheaderFontSize,
subtitleControl, subtitleControl,
subtitleFontSize, subtitleFontSize,
showMetricNameControl,
metricNameFontSizeWithVisibility,
} from '../sharedControls'; } from '../sharedControls';
import { ColorSchemeEnum } from './types'; import { ColorSchemeEnum } from './types';
@@ -70,6 +72,8 @@ const config: ControlPanelConfig = {
], ],
[subtitleControl], [subtitleControl],
[subtitleFontSize], [subtitleFontSize],
[showMetricNameControl],
[metricNameFontSizeWithVisibility],
[ [
{ {
...subheaderFontSize, ...subheaderFontSize,

View File

@@ -32,6 +32,7 @@ export default class PopKPIPlugin extends ChartPlugin {
tags: [ tags: [
t('Comparison'), t('Comparison'),
t('Business'), t('Business'),
t('ECharts'),
t('Percentages'), t('Percentages'),
t('Report'), t('Report'),
t('Advanced-Analytics'), t('Advanced-Analytics'),

View File

@@ -26,7 +26,13 @@ import {
SimpleAdhocFilter, SimpleAdhocFilter,
ensureIsArray, ensureIsArray,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { getComparisonFontSize, getHeaderFontSize } from './utils'; import {
getComparisonFontSize,
getHeaderFontSize,
getMetricNameFontSize,
} from './utils';
import { getOriginalLabel } from '../utils';
dayjs.extend(utc); dayjs.extend(utc);
@@ -83,6 +89,7 @@ export default function transformProps(chartProps: ChartProps) {
headerFontSize, headerFontSize,
headerText, headerText,
metric, metric,
metricNameFontSize,
yAxisFormat, yAxisFormat,
currencyFormat, currencyFormat,
subheaderFontSize, subheaderFontSize,
@@ -91,11 +98,14 @@ export default function transformProps(chartProps: ChartProps) {
percentDifferenceFormat, percentDifferenceFormat,
subtitle = '', subtitle = '',
subtitleFontSize, subtitleFontSize,
columnConfig, columnConfig = {},
} = formData; } = formData;
const { data: dataA = [] } = queriesData[0]; const { data: dataA = [] } = queriesData[0];
const data = dataA; const data = dataA;
const metricName = metric ? getMetricLabel(metric) : ''; const metricName = metric ? getMetricLabel(metric) : '';
const metrics = chartProps.datasource?.metrics || [];
const originalLabel = getOriginalLabel(metric, metrics);
const showMetricName = chartProps.rawFormData?.show_metric_name ?? false;
const timeComparison = ensureIsArray(chartProps.rawFormData?.time_compare)[0]; const timeComparison = ensureIsArray(chartProps.rawFormData?.time_compare)[0];
const startDateOffset = chartProps.rawFormData?.start_date_offset; const startDateOffset = chartProps.rawFormData?.start_date_offset;
const currentTimeRangeFilter = chartProps.rawFormData?.adhoc_filters?.filter( const currentTimeRangeFilter = chartProps.rawFormData?.adhoc_filters?.filter(
@@ -179,7 +189,7 @@ export default function transformProps(chartProps: ChartProps) {
width, width,
height, height,
data, data,
metricName, metricName: originalLabel,
bigNumber, bigNumber,
prevNumber, prevNumber,
valueDifference, valueDifference,
@@ -187,6 +197,8 @@ export default function transformProps(chartProps: ChartProps) {
boldText, boldText,
subtitle, subtitle,
subtitleFontSize, subtitleFontSize,
showMetricName,
metricNameFontSize: getMetricNameFontSize(metricNameFontSize),
headerFontSize: getHeaderFontSize(headerFontSize), headerFontSize: getHeaderFontSize(headerFontSize),
subheaderFontSize: getComparisonFontSize(subheaderFontSize), subheaderFontSize: getComparisonFontSize(subheaderFontSize),
headerText, headerText,

View File

@@ -61,6 +61,8 @@ export type PopKPIProps = PopKPIStylesProps &
data: TimeseriesDataRecord[]; data: TimeseriesDataRecord[];
metrics: Metric[]; metrics: Metric[];
metricName: string; metricName: string;
metricNameFontSize?: number;
showMetricName: boolean;
bigNumber: string; bigNumber: string;
prevNumber: string; prevNumber: string;
subtitle?: string; subtitle?: string;

View File

@@ -16,10 +16,19 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { headerFontSize, subheaderFontSize } from '../sharedControls'; import {
headerFontSize,
subheaderFontSize,
metricNameFontSize,
} from '../sharedControls';
const headerFontSizes = [16, 20, 30, 48, 60]; const headerFontSizes = [16, 20, 30, 48, 60];
const comparisonFontSizes = [16, 20, 26, 32, 40]; const sharedFontSizes = [16, 20, 26, 32, 40];
const metricNameProportionValues =
metricNameFontSize.config.options.map(
(option: { label: string; value: number }) => option.value,
) ?? [];
const headerProportionValues = const headerProportionValues =
headerFontSize.config.options.map( headerFontSize.config.options.map(
@@ -40,6 +49,10 @@ const getFontSizeMapping = (
return acc; return acc;
}, {}); }, {});
const metricNameFontSizesMapping = getFontSizeMapping(
metricNameProportionValues,
sharedFontSizes,
);
const headerFontSizesMapping = getFontSizeMapping( const headerFontSizesMapping = getFontSizeMapping(
headerProportionValues, headerProportionValues,
headerFontSizes, headerFontSizes,
@@ -47,13 +60,17 @@ const headerFontSizesMapping = getFontSizeMapping(
const comparisonFontSizesMapping = getFontSizeMapping( const comparisonFontSizesMapping = getFontSizeMapping(
subheaderProportionValues, subheaderProportionValues,
comparisonFontSizes, sharedFontSizes,
); );
export const getMetricNameFontSize = (proportionValue: number) =>
metricNameFontSizesMapping[proportionValue] ??
sharedFontSizes[sharedFontSizes.length - 1];
export const getHeaderFontSize = (proportionValue: number) => export const getHeaderFontSize = (proportionValue: number) =>
headerFontSizesMapping[proportionValue] ?? headerFontSizesMapping[proportionValue] ??
headerFontSizes[headerFontSizes.length - 1]; headerFontSizes[headerFontSizes.length - 1];
export const getComparisonFontSize = (proportionValue: number) => export const getComparisonFontSize = (proportionValue: number) =>
comparisonFontSizesMapping[proportionValue] ?? comparisonFontSizesMapping[proportionValue] ??
comparisonFontSizes[comparisonFontSizes.length - 1]; sharedFontSizes[sharedFontSizes.length - 1];

View File

@@ -28,6 +28,8 @@ import {
headerFontSize, headerFontSize,
subtitleFontSize, subtitleFontSize,
subtitleControl, subtitleControl,
showMetricNameControl,
metricNameFontSizeWithVisibility,
} from '../sharedControls'; } from '../sharedControls';
export default { export default {
@@ -44,6 +46,8 @@ export default {
[headerFontSize], [headerFontSize],
[subtitleControl], [subtitleControl],
[subtitleFontSize], [subtitleFontSize],
[showMetricNameControl],
[metricNameFontSizeWithVisibility],
['y_axis_format'], ['y_axis_format'],
['currency_format'], ['currency_format'],
[ [

View File

@@ -39,6 +39,7 @@ const metadata = {
tags: [ tags: [
t('Additive'), t('Additive'),
t('Business'), t('Business'),
t('ECharts'),
t('Legacy'), t('Legacy'),
t('Percentages'), t('Percentages'),
t('Featured'), t('Featured'),

View File

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

View File

@@ -29,7 +29,7 @@ import {
getValueFormatter, getValueFormatter,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { BigNumberTotalChartProps, BigNumberVizProps } from '../types'; import { BigNumberTotalChartProps, BigNumberVizProps } from '../types';
import { getDateFormatter, parseMetricValue } from '../utils'; import { getDateFormatter, getOriginalLabel, parseMetricValue } from '../utils';
import { Refs } from '../../types'; import { Refs } from '../../types';
export default function transformProps( export default function transformProps(
@@ -45,6 +45,7 @@ export default function transformProps(
datasource: { currencyFormats = {}, columnFormats = {} }, datasource: { currencyFormats = {}, columnFormats = {} },
} = chartProps; } = chartProps;
const { const {
metricNameFontSize,
headerFontSize, headerFontSize,
metric = 'value', metric = 'value',
subtitle, subtitle,
@@ -58,9 +59,12 @@ export default function transformProps(
subheaderFontSize, subheaderFontSize,
} = formData; } = formData;
const refs: Refs = {}; const refs: Refs = {};
const { data = [], coltypes = [] } = queriesData[0]; const { data = [], coltypes = [] } = queriesData[0] || {};
const granularity = extractTimegrain(rawFormData as QueryFormData); const granularity = extractTimegrain(rawFormData as QueryFormData);
const metrics = chartProps.datasource?.metrics || [];
const originalLabel = getOriginalLabel(metric, metrics);
const metricName = getMetricLabel(metric); const metricName = getMetricLabel(metric);
const showMetricName = chartProps.rawFormData?.show_metric_name ?? false;
const formattedSubtitle = subtitle?.trim() ? subtitle : subheader || ''; const formattedSubtitle = subtitle?.trim() ? subtitle : subheader || '';
const formattedSubtitleFontSize = subtitle?.trim() const formattedSubtitleFontSize = subtitle?.trim()
? (subtitleFontSize ?? 1) ? (subtitleFontSize ?? 1)
@@ -103,7 +107,6 @@ export default function transformProps(
const colorThresholdFormatters = const colorThresholdFormatters =
getColorFormatters(conditionalFormatting, data, false) ?? getColorFormatters(conditionalFormatting, data, false) ??
defaultColorFormatters; defaultColorFormatters;
return { return {
width, width,
height, height,
@@ -116,5 +119,8 @@ export default function transformProps(
onContextMenu, onContextMenu,
refs, refs,
colorThresholdFormatters, colorThresholdFormatters,
metricName: originalLabel,
showMetricName,
metricNameFontSize,
}; };
} }

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { PureComponent, MouseEvent } from 'react'; import { PureComponent, MouseEvent, createRef } from 'react';
import { import {
t, t,
getNumberFormatter, getNumberFormatter,
@@ -35,6 +35,7 @@ const defaultNumberFormatter = getNumberFormatter();
const PROPORTION = { const PROPORTION = {
// text size: proportion of the chart container sans trendline // text size: proportion of the chart container sans trendline
METRIC_NAME: 0.125,
KICKER: 0.1, KICKER: 0.1,
HEADER: 0.3, HEADER: 0.3,
SUBHEADER: 0.125, SUBHEADER: 0.125,
@@ -42,13 +43,20 @@ const PROPORTION = {
TRENDLINE: 0.3, TRENDLINE: 0.3,
}; };
class BigNumberVis extends PureComponent<BigNumberVizProps> { type BigNumberVisState = {
elementsRendered: boolean;
recalculateTrigger: boolean;
};
class BigNumberVis extends PureComponent<BigNumberVizProps, BigNumberVisState> {
static defaultProps = { static defaultProps = {
className: '', className: '',
headerFormatter: defaultNumberFormatter, headerFormatter: defaultNumberFormatter,
formatTime: getTimeFormatter(SMART_DATE_VERBOSE_ID), formatTime: getTimeFormatter(SMART_DATE_VERBOSE_ID),
headerFontSize: PROPORTION.HEADER, headerFontSize: PROPORTION.HEADER,
kickerFontSize: PROPORTION.KICKER, kickerFontSize: PROPORTION.KICKER,
metricNameFontSize: PROPORTION.METRIC_NAME,
showMetricName: true,
mainColor: BRAND_COLOR, mainColor: BRAND_COLOR,
showTimestamp: false, showTimestamp: false,
showTrendLine: false, showTrendLine: false,
@@ -58,6 +66,40 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
timeRangeFixed: false, timeRangeFixed: false,
}; };
// Create refs for each component to measure heights
metricNameRef = createRef<HTMLDivElement>();
kickerRef = createRef<HTMLDivElement>();
headerRef = createRef<HTMLDivElement>();
subheaderRef = createRef<HTMLDivElement>();
subtitleRef = createRef<HTMLDivElement>();
state = {
elementsRendered: false,
recalculateTrigger: false,
};
componentDidMount() {
// Wait for elements to render and then calculate heights
setTimeout(() => {
this.setState({ elementsRendered: true });
}, 0);
}
componentDidUpdate(prevProps: BigNumberVizProps) {
if (
prevProps.height !== this.props.height ||
prevProps.showTrendLine !== this.props.showTrendLine
) {
this.setState(prevState => ({
recalculateTrigger: !prevState.recalculateTrigger,
}));
}
}
getClassName() { getClassName() {
const { className, showTrendLine, bigNumberFallback } = this.props; const { className, showTrendLine, bigNumberFallback } = this.props;
const names = `superset-legacy-chart-big-number ${className} ${ const names = `superset-legacy-chart-big-number ${className} ${
@@ -92,6 +134,37 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
); );
} }
renderMetricName(maxHeight: number) {
const { metricName, width, showMetricName } = this.props;
if (!showMetricName || !metricName) return null;
const text = metricName;
const container = this.createTemporaryContainer();
document.body.append(container);
const fontSize = computeMaxFontSize({
text,
maxWidth: width,
maxHeight,
className: 'metric-name',
container,
});
container.remove();
return (
<div
ref={this.metricNameRef}
className="metric-name"
style={{
fontSize,
height: 'auto',
}}
>
{text}
</div>
);
}
renderKicker(maxHeight: number) { renderKicker(maxHeight: number) {
const { timestamp, showTimestamp, formatTime, width } = this.props; const { timestamp, showTimestamp, formatTime, width } = this.props;
if ( if (
@@ -118,6 +191,7 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
return ( return (
<div <div
ref={this.kickerRef}
className="kicker" className="kicker"
style={{ style={{
fontSize, fontSize,
@@ -173,6 +247,7 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
return ( return (
<div <div
ref={this.headerRef}
className="header-line" className="header-line"
style={{ style={{
display: 'flex', display: 'flex',
@@ -211,6 +286,7 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
return ( return (
<div <div
ref={this.subheaderRef}
className="subheader-line" className="subheader-line"
style={{ style={{
fontSize, fontSize,
@@ -256,6 +332,7 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
return ( return (
<> <>
<div <div
ref={this.subtitleRef}
className="subtitle-line subheader-line" className="subtitle-line subheader-line"
style={{ style={{
fontSize: `${fontSize}px`, fontSize: `${fontSize}px`,
@@ -316,6 +393,35 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
); );
} }
getTotalElementsHeight() {
const marginPerElement = 8; // theme.gridUnit = 4, so margin-bottom = 8px
const refs = [
this.metricNameRef,
this.kickerRef,
this.headerRef,
this.subheaderRef,
this.subtitleRef,
];
// Filter refs to only those with a current element
const visibleRefs = refs.filter(ref => ref.current);
const totalHeight = visibleRefs.reduce((sum, ref, index) => {
const height = ref.current?.offsetHeight || 0;
const margin = index < visibleRefs.length - 1 ? marginPerElement : 0;
return sum + height + margin;
}, 0);
return totalHeight;
}
shouldApplyOverflow(availableHeight: number) {
if (!this.state.elementsRendered) return false;
const totalHeight = this.getTotalElementsHeight();
return totalHeight > availableHeight;
}
render() { render() {
const { const {
showTrendLine, showTrendLine,
@@ -323,6 +429,7 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
kickerFontSize, kickerFontSize,
headerFontSize, headerFontSize,
subtitleFontSize, subtitleFontSize,
metricNameFontSize,
subheaderFontSize, subheaderFontSize,
} = this.props; } = this.props;
const className = this.getClassName(); const className = this.getClassName();
@@ -330,11 +437,31 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
if (showTrendLine) { if (showTrendLine) {
const chartHeight = Math.floor(PROPORTION.TRENDLINE * height); const chartHeight = Math.floor(PROPORTION.TRENDLINE * height);
const allTextHeight = height - chartHeight; const allTextHeight = height - chartHeight;
const shouldApplyOverflow = this.shouldApplyOverflow(allTextHeight);
return ( return (
<div className={className}> <div className={className}>
<div className="text-container" style={{ height: allTextHeight }}> <div
className="text-container"
style={{
height: allTextHeight,
...(shouldApplyOverflow
? {
display: 'block',
boxSizing: 'border-box',
overflowX: 'hidden',
overflowY: 'auto',
width: '100%',
}
: {}),
}}
>
{this.renderFallbackWarning()} {this.renderFallbackWarning()}
{this.renderMetricName(
Math.ceil(
(metricNameFontSize || 0) * (1 - PROPORTION.TRENDLINE) * height,
),
)}
{this.renderKicker( {this.renderKicker(
Math.ceil( Math.ceil(
(kickerFontSize || 0) * (1 - PROPORTION.TRENDLINE) * height, (kickerFontSize || 0) * (1 - PROPORTION.TRENDLINE) * height,
@@ -356,16 +483,33 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
</div> </div>
); );
} }
const shouldApplyOverflow = this.shouldApplyOverflow(height);
return ( return (
<div className={className} style={{ height }}> <div
{this.renderFallbackWarning()} className={className}
{this.renderKicker((kickerFontSize || 0) * height)} style={{
{this.renderHeader(Math.ceil(headerFontSize * height))} height,
{this.rendermetricComparisonSummary( ...(shouldApplyOverflow
Math.ceil(subheaderFontSize * height), ? {
)} display: 'block',
{this.renderSubtitle(Math.ceil(subtitleFontSize * height))} boxSizing: 'border-box',
overflowX: 'hidden',
overflowY: 'auto',
width: '100%',
}
: {}),
}}
>
<div className="text-container">
{this.renderFallbackWarning()}
{this.renderMetricName((metricNameFontSize || 0) * height)}
{this.renderKicker((kickerFontSize || 0) * height)}
{this.renderHeader(Math.ceil(headerFontSize * height))}
{this.rendermetricComparisonSummary(
Math.ceil(subheaderFontSize * height),
)}
{this.renderSubtitle(Math.ceil(subtitleFontSize * height))}
</div>
</div> </div>
); );
} }
@@ -400,7 +544,12 @@ export default styled(BigNumberVis)`
.kicker { .kicker {
line-height: 1em; line-height: 1em;
padding-bottom: 2em; margin-bottom: ${theme.gridUnit * 2}px;
}
.metric-name {
line-height: 1em;
margin-bottom: ${theme.gridUnit * 2}px;
} }
.header-line { .header-line {
@@ -416,12 +565,12 @@ export default styled(BigNumberVis)`
.subheader-line { .subheader-line {
line-height: 1em; line-height: 1em;
padding-bottom: 0; margin-bottom: ${theme.gridUnit * 2}px;
} }
.subtitle-line { .subtitle-line {
line-height: 1em; line-height: 1em;
padding-bottom: 0; margin-bottom: ${theme.gridUnit * 2}px;
} }
&.is-fallback-value { &.is-fallback-value {

View File

@@ -31,6 +31,8 @@ import {
subheaderFontSize, subheaderFontSize,
subtitleFontSize, subtitleFontSize,
subtitleControl, subtitleControl,
showMetricNameControl,
metricNameFontSizeWithVisibility,
} from '../sharedControls'; } from '../sharedControls';
const config: ControlPanelConfig = { const config: ControlPanelConfig = {
@@ -141,6 +143,8 @@ const config: ControlPanelConfig = {
[subheaderFontSize], [subheaderFontSize],
[subtitleControl], [subtitleControl],
[subtitleFontSize], [subtitleFontSize],
[showMetricNameControl],
[metricNameFontSizeWithVisibility],
['y_axis_format'], ['y_axis_format'],
['currency_format'], ['currency_format'],
[ [

View File

@@ -37,6 +37,7 @@ const metadata = {
name: t('Big Number with Trendline'), name: t('Big Number with Trendline'),
tags: [ tags: [
t('Advanced-Analytics'), t('Advanced-Analytics'),
t('ECharts'),
t('Line'), t('Line'),
t('Percentages'), t('Percentages'),
t('Featured'), t('Featured'),

View File

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

View File

@@ -35,7 +35,7 @@ import {
BigNumberWithTrendlineChartProps, BigNumberWithTrendlineChartProps,
TimeSeriesDatum, TimeSeriesDatum,
} from '../types'; } from '../types';
import { getDateFormatter, parseMetricValue } from '../utils'; import { getDateFormatter, parseMetricValue, getOriginalLabel } from '../utils';
import { getDefaultTooltip } from '../../utils/tooltip'; import { getDefaultTooltip } from '../../utils/tooltip';
import { Refs } from '../../types'; import { Refs } from '../../types';
@@ -62,6 +62,7 @@ export default function transformProps(
compareLag: compareLag_, compareLag: compareLag_,
compareSuffix = '', compareSuffix = '',
timeFormat, timeFormat,
metricNameFontSize,
headerFontSize, headerFontSize,
metric = 'value', metric = 'value',
showTimestamp, showTimestamp,
@@ -96,6 +97,9 @@ export default function transformProps(
const aggregatedData = hasAggregatedData ? aggregatedQueryData.data[0] : null; const aggregatedData = hasAggregatedData ? aggregatedQueryData.data[0] : null;
const refs: Refs = {}; const refs: Refs = {};
const metricName = getMetricLabel(metric); const metricName = getMetricLabel(metric);
const metrics = chartProps.datasource?.metrics || [];
const originalLabel = getOriginalLabel(metric, metrics);
const showMetricName = chartProps.rawFormData?.show_metric_name ?? false;
const compareLag = Number(compareLag_) || 0; const compareLag = Number(compareLag_) || 0;
let formattedSubheader = subheader; let formattedSubheader = subheader;
@@ -303,6 +307,9 @@ export default function transformProps(
headerFormatter, headerFormatter,
formatTime, formatTime,
formData, formData,
metricName: originalLabel,
showMetricName,
metricNameFontSize,
headerFontSize, headerFontSize,
subtitleFontSize, subtitleFontSize,
subtitle, subtitle,

View File

@@ -21,106 +21,68 @@
import { t } from '@superset-ui/core'; import { t } from '@superset-ui/core';
import { CustomControlItem } from '@superset-ui/chart-controls'; import { CustomControlItem } from '@superset-ui/chart-controls';
export const headerFontSize: CustomControlItem = { const FONT_SIZE_OPTIONS_SMALL = [
name: 'header_font_size', { label: t('Tiny'), value: 0.125 },
config: { { label: t('Small'), value: 0.15 },
type: 'SelectControl', { label: t('Normal'), value: 0.2 },
label: t('Big Number Font Size'), { label: t('Large'), value: 0.3 },
renderTrigger: true, { label: t('Huge'), value: 0.4 },
clearable: false, ];
default: 0.4,
// Values represent the percentage of space a header should take
options: [
{
label: t('Tiny'),
value: 0.2,
},
{
label: t('Small'),
value: 0.3,
},
{
label: t('Normal'),
value: 0.4,
},
{
label: t('Large'),
value: 0.5,
},
{
label: t('Huge'),
value: 0.6,
},
],
},
};
export const subtitleFontSize: CustomControlItem = { const FONT_SIZE_OPTIONS_LARGE = [
name: 'subtitle_font_size', { label: t('Tiny'), value: 0.2 },
config: { { label: t('Small'), value: 0.3 },
type: 'SelectControl', { label: t('Normal'), value: 0.4 },
label: t('Subtitle Font Size'), { label: t('Large'), value: 0.5 },
renderTrigger: true, { label: t('Huge'), value: 0.6 },
clearable: false, ];
default: 0.15,
// Values represent the percentage of space a subtitle should take function makeFontSizeControl(
options: [ name: string,
{ label: string,
label: t('Tiny'), defaultValue: number,
value: 0.125, options: { label: string; value: number }[],
}, ): CustomControlItem {
{ return {
label: t('Small'), name,
value: 0.15, config: {
}, type: 'SelectControl',
{ label: t(label),
label: t('Normal'), renderTrigger: true,
value: 0.2, clearable: false,
}, default: defaultValue,
{ options,
label: t('Large'), },
value: 0.3, };
}, }
{
label: t('Huge'), export const headerFontSize = makeFontSizeControl(
value: 0.4, 'header_font_size',
}, 'Big Number Font Size',
], 0.4,
}, FONT_SIZE_OPTIONS_LARGE,
}; );
export const subheaderFontSize: CustomControlItem = {
name: 'subheader_font_size', export const subtitleFontSize = makeFontSizeControl(
config: { 'subtitle_font_size',
type: 'SelectControl', 'Subtitle Font Size',
label: t('Subheader Font Size'), 0.15,
renderTrigger: true, FONT_SIZE_OPTIONS_SMALL,
clearable: false, );
default: 0.15,
// Values represent the percentage of space a subheader should take export const subheaderFontSize = makeFontSizeControl(
options: [ 'subheader_font_size',
{ 'Subheader Font Size',
label: t('Tiny'), 0.15,
value: 0.125, FONT_SIZE_OPTIONS_SMALL,
}, );
{
label: t('Small'), export const metricNameFontSize = makeFontSizeControl(
value: 0.15, 'metric_name_font_size',
}, 'Metric Name Font Size',
{ 0.15,
label: t('Normal'), FONT_SIZE_OPTIONS_SMALL,
value: 0.2, );
},
{
label: t('Large'),
value: 0.3,
},
{
label: t('Huge'),
value: 0.4,
},
],
},
};
export const subtitleControl: CustomControlItem = { export const subtitleControl: CustomControlItem = {
name: 'subtitle', name: 'subtitle',
@@ -131,3 +93,23 @@ export const subtitleControl: CustomControlItem = {
description: t('Description text that shows up below your Big Number'), description: t('Description text that shows up below your Big Number'),
}, },
}; };
export const showMetricNameControl: CustomControlItem = {
name: 'show_metric_name',
config: {
type: 'CheckboxControl',
label: t('Show Metric Name'),
renderTrigger: true,
default: false,
description: t('Whether to display the metric name'),
},
};
export const metricNameFontSizeWithVisibility: CustomControlItem = {
...metricNameFontSize,
config: {
...metricNameFontSize.config,
visibility: ({ controls }) => controls?.show_metric_name?.value === true,
resetOnHide: false,
},
};

View File

@@ -75,6 +75,10 @@ export type BigNumberVizProps = {
bigNumberFallback?: TimeSeriesDatum; bigNumberFallback?: TimeSeriesDatum;
headerFormatter: ValueFormatter | TimeFormatter; headerFormatter: ValueFormatter | TimeFormatter;
formatTime?: TimeFormatter; formatTime?: TimeFormatter;
metricName?: string;
friendlyMetricName?: string;
metricNameFontSize?: number;
showMetricName?: boolean;
headerFontSize: number; headerFontSize: number;
kickerFontSize?: number; kickerFontSize?: number;
subheader?: string; subheader?: string;

View File

@@ -22,6 +22,10 @@ import utc from 'dayjs/plugin/utc';
import { import {
getTimeFormatter, getTimeFormatter,
getTimeFormatterForGranularity, getTimeFormatterForGranularity,
isAdhocMetricSimple,
isSavedMetric,
Metric,
QueryFormMetric,
SMART_DATE_ID, SMART_DATE_ID,
TimeGranularity, TimeGranularity,
} from '@superset-ui/core'; } from '@superset-ui/core';
@@ -47,3 +51,43 @@ export const getDateFormatter = (
timeFormat === SMART_DATE_ID timeFormat === SMART_DATE_ID
? getTimeFormatterForGranularity(granularity) ? getTimeFormatterForGranularity(granularity)
: getTimeFormatter(timeFormat ?? fallbackFormat); : getTimeFormatter(timeFormat ?? fallbackFormat);
export function getOriginalLabel(
metric: QueryFormMetric,
metrics: Metric[] = [],
): string {
const metricLabel = typeof metric === 'string' ? metric : metric.label || '';
if (isSavedMetric(metric)) {
const metricEntry = metrics.find(m => m.metric_name === metric);
return (
metricEntry?.verbose_name ||
metricEntry?.metric_name ||
metric ||
'Unknown Metric'
);
}
if (isAdhocMetricSimple(metric)) {
const column = metric.column || {};
const columnName = column.column_name || 'unknown_column';
const verboseName = column.verbose_name || columnName;
const aggregate = metric.aggregate || 'UNKNOWN';
return metric.hasCustomLabel && metric.label
? metric.label
: `${aggregate}(${verboseName})`;
}
if (
typeof metric === 'object' &&
'expressionType' in metric &&
metric.expressionType === 'SQL' &&
'sqlExpression' in metric
) {
return metric.hasCustomLabel && metric.label
? metric.label
: metricLabel || 'Custom Metric';
}
return metricLabel || 'Unknown Metric';
}

View File

@@ -16,14 +16,30 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { buildQueryContext, QueryFormData } from '@superset-ui/core'; import {
buildQueryContext,
getMetricLabel,
QueryFormData,
} from '@superset-ui/core';
import { getContributionLabel } from './utils';
export default function buildQuery(formData: QueryFormData) { export default function buildQuery(formData: QueryFormData) {
const { metric, sort_by_metric } = formData; const { metric, sort_by_metric } = formData;
const metricLabel = getMetricLabel(metric);
return buildQueryContext(formData, baseQueryObject => [ return buildQueryContext(formData, baseQueryObject => [
{ {
...baseQueryObject, ...baseQueryObject,
...(sort_by_metric && { orderby: [[metric, false]] }), ...(sort_by_metric && { orderby: [[metric, false]] }),
post_processing: [
{
operation: 'contribution',
options: {
columns: [metricLabel],
rename_columns: [getContributionLabel(metricLabel)],
},
},
],
}, },
]); ]);
} }

View File

@@ -0,0 +1,19 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const CONTRIBUTION_SUFFIX = '__contribution' as const;

View File

@@ -84,6 +84,23 @@ const config: ControlPanelConfig = {
}, },
}, },
], ],
[
{
name: 'threshold_for_other',
config: {
type: 'NumberControl',
label: t('Threshold for Other'),
min: 0,
step: 0.5,
max: 100,
default: 0,
renderTrigger: true,
description: t(
'Values less than this percentage will be grouped into the Other category.',
),
},
},
],
[ [
{ {
name: 'roseType', name: 'roseType',

View File

@@ -27,6 +27,7 @@ import {
ValueFormatter, ValueFormatter,
getValueFormatter, getValueFormatter,
tooltipHtml, tooltipHtml,
DataRecord,
} from '@superset-ui/core'; } from '@superset-ui/core';
import type { CallbackDataParams } from 'echarts/types/src/util/types'; import type { CallbackDataParams } from 'echarts/types/src/util/types';
import type { EChartsCoreOption } from 'echarts/core'; import type { EChartsCoreOption } from 'echarts/core';
@@ -36,6 +37,7 @@ import {
EchartsPieChartProps, EchartsPieChartProps,
EchartsPieFormData, EchartsPieFormData,
EchartsPieLabelType, EchartsPieLabelType,
PieChartDataItem,
PieChartTransformedProps, PieChartTransformedProps,
} from './types'; } from './types';
import { DEFAULT_LEGEND_FORM_DATA, OpacityEnum } from '../constants'; import { DEFAULT_LEGEND_FORM_DATA, OpacityEnum } from '../constants';
@@ -50,6 +52,7 @@ import { defaultGrid } from '../defaults';
import { convertInteger } from '../utils/convertInteger'; import { convertInteger } from '../utils/convertInteger';
import { getDefaultTooltip } from '../utils/tooltip'; import { getDefaultTooltip } from '../utils/tooltip';
import { Refs } from '../types'; import { Refs } from '../types';
import { getContributionLabel } from './utils';
const percentFormatter = getNumberFormatter(NumberFormats.PERCENT_2_POINT); const percentFormatter = getNumberFormatter(NumberFormats.PERCENT_2_POINT);
@@ -133,7 +136,7 @@ export default function transformProps(
datasource, datasource,
} = chartProps; } = chartProps;
const { columnFormats = {}, currencyFormats = {} } = datasource; const { columnFormats = {}, currencyFormats = {} } = datasource;
const { data = [] } = queriesData[0]; const { data: rawData = [] } = queriesData[0];
const coltypeMapping = getColtypesMapping(queriesData[0]); const coltypeMapping = getColtypesMapping(queriesData[0]);
const { const {
@@ -159,6 +162,7 @@ export default function transformProps(
sliceId, sliceId,
showTotal, showTotal,
roseType, roseType,
thresholdForOther,
}: EchartsPieFormData = { }: EchartsPieFormData = {
...DEFAULT_LEGEND_FORM_DATA, ...DEFAULT_LEGEND_FORM_DATA,
...DEFAULT_PIE_FORM_DATA, ...DEFAULT_PIE_FORM_DATA,
@@ -166,17 +170,68 @@ export default function transformProps(
}; };
const refs: Refs = {}; const refs: Refs = {};
const metricLabel = getMetricLabel(metric); const metricLabel = getMetricLabel(metric);
const contributionLabel = getContributionLabel(metricLabel);
const groupbyLabels = groupby.map(getColumnLabel); const groupbyLabels = groupby.map(getColumnLabel);
const minShowLabelAngle = (showLabelsThreshold || 0) * 3.6; const minShowLabelAngle = (showLabelsThreshold || 0) * 3.6;
const keys = data.map(datum => const numberFormatter = getValueFormatter(
extractGroupbyLabel({ metric,
datum, currencyFormats,
groupby: groupbyLabels, columnFormats,
coltypeMapping, numberFormat,
timeFormatter: getTimeFormatter(dateFormat), currencyFormat,
}),
); );
let data = rawData;
const otherRows: DataRecord[] = [];
const otherTooltipData: string[][] = [];
let otherDatum: PieChartDataItem | null = null;
let otherSum = 0;
if (thresholdForOther) {
let contributionSum = 0;
data = data.filter(datum => {
const contribution = datum[contributionLabel] as number;
if (!contribution || contribution * 100 >= thresholdForOther) {
return true;
}
otherSum += datum[metricLabel] as number;
contributionSum += contribution;
otherRows.push(datum);
otherTooltipData.push([
extractGroupbyLabel({
datum,
groupby: groupbyLabels,
coltypeMapping,
timeFormatter: getTimeFormatter(dateFormat),
}),
numberFormatter(datum[metricLabel] as number),
percentFormatter(contribution),
]);
return false;
});
const otherName = t('Other');
otherTooltipData.push([
t('Total'),
numberFormatter(otherSum),
percentFormatter(contributionSum),
]);
if (otherSum) {
otherDatum = {
name: otherName,
value: otherSum,
itemStyle: {
color: theme.colors.grayscale.dark1,
opacity:
filterState.selectedValues &&
!filterState.selectedValues.includes(otherName)
? OpacityEnum.SemiTransparent
: OpacityEnum.NonTransparent,
},
isOther: true,
};
}
}
const labelMap = data.reduce((acc: Record<string, string[]>, datum) => { const labelMap = data.reduce((acc: Record<string, string[]>, datum) => {
const label = extractGroupbyLabel({ const label = extractGroupbyLabel({
datum, datum,
@@ -192,13 +247,6 @@ export default function transformProps(
const { setDataMask = () => {}, onContextMenu } = hooks; const { setDataMask = () => {}, onContextMenu } = hooks;
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string); const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
const numberFormatter = getValueFormatter(
metric,
currencyFormats,
columnFormats,
numberFormat,
currencyFormat,
);
let totalValue = 0; let totalValue = 0;
@@ -229,6 +277,10 @@ export default function transformProps(
}, },
}; };
}); });
if (otherDatum) {
transformedData.push(otherDatum);
totalValue += otherSum;
}
const selectedValues = (filterState.selectedValues || []).reduce( const selectedValues = (filterState.selectedValues || []).reduce(
(acc: Record<string, number>, selectedValue: string) => { (acc: Record<string, number>, selectedValue: string) => {
@@ -372,6 +424,9 @@ export default function transformProps(
numberFormatter, numberFormatter,
sanitizeName: true, sanitizeName: true,
}); });
if (params?.data?.isOther) {
return tooltipHtml(otherTooltipData, name);
}
return tooltipHtml( return tooltipHtml(
[[metricLabel, formattedValue, formattedPercent]], [[metricLabel, formattedValue, formattedPercent]],
name, name,
@@ -380,7 +435,7 @@ export default function transformProps(
}, },
legend: { legend: {
...getLegendProps(legendType, legendOrientation, showLegend, theme), ...getLegendProps(legendType, legendOrientation, showLegend, theme),
data: keys, data: transformedData.map(datum => datum.name),
}, },
graphic: showTotal graphic: showTotal
? { ? {

View File

@@ -47,6 +47,7 @@ export type EchartsPieFormData = QueryFormData &
dateFormat: string; dateFormat: string;
showLabelsThreshold: number; showLabelsThreshold: number;
roseType: 'radius' | 'area' | null; roseType: 'radius' | 'area' | null;
thresholdForOther: number;
}; };
export enum EchartsPieLabelType { export enum EchartsPieLabelType {
@@ -82,9 +83,20 @@ export const DEFAULT_FORM_DATA: EchartsPieFormData = {
showLabelsThreshold: 5, showLabelsThreshold: 5,
dateFormat: 'smart_date', dateFormat: 'smart_date',
roseType: null, roseType: null,
thresholdForOther: 0,
}; };
export type PieChartTransformedProps = export type PieChartTransformedProps =
BaseTransformedProps<EchartsPieFormData> & BaseTransformedProps<EchartsPieFormData> &
ContextMenuTransformedProps & ContextMenuTransformedProps &
CrossFilterTransformedProps; CrossFilterTransformedProps;
export interface PieChartDataItem {
name: string;
value: number;
itemStyle: {
color: string;
opacity: number;
};
isOther?: boolean;
}

View File

@@ -0,0 +1,22 @@
/**
* 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 { CONTRIBUTION_SUFFIX } from './constants';
export const getContributionLabel = (metricLabel: string) =>
`${metricLabel}${CONTRIBUTION_SUFFIX}`;

View File

@@ -57,7 +57,7 @@ export default class EchartsSankeyChartPlugin extends ChartPlugin<
), ),
exampleGallery: [{ url: example1 }, { url: example2 }], exampleGallery: [{ url: example1 }, { url: example2 }],
name: t('Sankey Chart'), name: t('Sankey Chart'),
tags: [t('Directional'), t('Distribution'), t('Flow')], tags: [t('Directional'), t('ECharts'), t('Distribution'), t('Flow')],
thumbnail, thumbnail,
}), }),
transformProps, transformProps,

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { t } from '@superset-ui/core'; import { JsonArray, t } from '@superset-ui/core';
import { import {
ControlPanelConfig, ControlPanelConfig,
ControlPanelsContainerProps, ControlPanelsContainerProps,
@@ -45,6 +45,7 @@ import {
DEFAULT_FORM_DATA, DEFAULT_FORM_DATA,
TIME_SERIES_DESCRIPTION_TEXT, TIME_SERIES_DESCRIPTION_TEXT,
} from '../../constants'; } from '../../constants';
import { StackControlsValue } from '../../../constants';
const { const {
logAxis, logAxis,
@@ -321,6 +322,38 @@ const config: ControlPanelConfig = {
['color_scheme'], ['color_scheme'],
['time_shift_color'], ['time_shift_color'],
...showValueSection, ...showValueSection,
[
{
name: 'stackDimension',
config: {
type: 'SelectControl',
label: t('Split stack by'),
visibility: ({ controls }) =>
controls?.stack?.value === StackControlsValue.Stack,
renderTrigger: true,
description: t(
'Stack in groups, where each group corresponds to a dimension',
),
shouldMapStateToProps: (
prevState,
state,
controlState,
chartState,
) => true,
mapStateToProps: (state, controlState, chartState) => {
const value: JsonArray = state.controls.groupby
.value as JsonArray;
const valueAsStringArr: string[][] = value.map(v => {
if (v) return [v.toString(), v.toString()];
return ['', ''];
});
return {
choices: valueAsStringArr,
};
},
},
},
],
[minorTicks], [minorTicks],
[ [
{ {

View File

@@ -58,6 +58,7 @@ export default class EchartsTimeseriesChartPlugin extends EchartsChartPlugin<
name: t('Generic Chart'), name: t('Generic Chart'),
tags: [ tags: [
t('Advanced-Analytics'), t('Advanced-Analytics'),
t('ECharts'),
t('Line'), t('Line'),
t('Predictive'), t('Predictive'),
t('Time'), t('Time'),

View File

@@ -191,6 +191,7 @@ export default function transformProps(
yAxisTitleMargin, yAxisTitleMargin,
yAxisTitlePosition, yAxisTitlePosition,
zoomable, zoomable,
stackDimension,
}: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData }; }: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
const refs: Refs = {}; const refs: Refs = {};
const groupBy = ensureIsArray(groupby); const groupBy = ensureIsArray(groupby);
@@ -418,6 +419,23 @@ export default function transformProps(
} }
}); });
if (
stack === StackControlsValue.Stack &&
stackDimension &&
chartProps.rawFormData.groupby
) {
const idxSelectedDimension =
formData.metrics.length > 1
? 1
: 0 + chartProps.rawFormData.groupby.indexOf(stackDimension);
for (const s of series) {
if (s.id) {
const columnsArr = labelMap[s.id];
(s as any).stack = columnsArr[idxSelectedDimension];
}
}
}
// axis bounds need to be parsed to replace incompatible values with undefined // axis bounds need to be parsed to replace incompatible values with undefined
const [xAxisMin, xAxisMax] = (xAxisBounds || []).map(parseAxisBound); const [xAxisMin, xAxisMax] = (xAxisBounds || []).map(parseAxisBound);
let [yAxisMin, yAxisMax] = (yAxisBounds || []).map(parseAxisBound); let [yAxisMin, yAxisMax] = (yAxisBounds || []).map(parseAxisBound);

View File

@@ -74,6 +74,7 @@ export type EchartsTimeseriesFormData = QueryFormData & {
rowLimit: number; rowLimit: number;
seriesType: EchartsTimeseriesSeriesType; seriesType: EchartsTimeseriesSeriesType;
stack: StackType; stack: StackType;
stackDimension: string;
timeCompare?: string[]; timeCompare?: string[];
tooltipTimeFormat?: string; tooltipTimeFormat?: string;
showTooltipTotal?: boolean; showTooltipTotal?: boolean;

View File

@@ -28,7 +28,7 @@ import type {
CallbackDataParams, CallbackDataParams,
} from 'echarts/types/src/util/types'; } from 'echarts/types/src/util/types';
import transformProps, { parseParams } from '../../src/Pie/transformProps'; import transformProps, { parseParams } from '../../src/Pie/transformProps';
import { EchartsPieChartProps } from '../../src/Pie/types'; import { EchartsPieChartProps, PieChartDataItem } from '../../src/Pie/types';
describe('Pie transformProps', () => { describe('Pie transformProps', () => {
const formData: SqlaFormData = { const formData: SqlaFormData = {
@@ -46,8 +46,13 @@ describe('Pie transformProps', () => {
queriesData: [ queriesData: [
{ {
data: [ data: [
{ foo: 'Sylvester', bar: 1, sum__num: 10 }, {
{ foo: 'Arnold', bar: 2, sum__num: 2.5 }, foo: 'Sylvester',
bar: 1,
sum__num: 10,
sum__num__contribution: 0.8,
},
{ foo: 'Arnold', bar: 2, sum__num: 2.5, sum__num__contribution: 0.2 },
], ],
}, },
], ],
@@ -215,3 +220,77 @@ describe('Pie label string template', () => {
).toEqual('Tablet:123456\n55.5'); ).toEqual('Tablet:123456\n55.5');
}); });
}); });
describe('Other category', () => {
const defaultFormData: SqlaFormData = {
colorScheme: 'bnbColors',
datasource: '3__table',
granularity_sqla: 'ds',
metric: 'metric',
groupby: ['foo', 'bar'],
viz_type: 'my_viz',
};
const getChartProps = (formData: Partial<SqlaFormData>) =>
new ChartProps({
formData: {
...defaultFormData,
...formData,
},
width: 800,
height: 600,
queriesData: [
{
data: [
{
foo: 'foo 1',
bar: 'bar 1',
metric: 1,
metric__contribution: 1 / 15, // 6.7%
},
{
foo: 'foo 2',
bar: 'bar 2',
metric: 2,
metric__contribution: 2 / 15, // 13.3%
},
{
foo: 'foo 3',
bar: 'bar 3',
metric: 3,
metric__contribution: 3 / 15, // 20%
},
{
foo: 'foo 4',
bar: 'bar 4',
metric: 4,
metric__contribution: 4 / 15, // 26.7%
},
{
foo: 'foo 5',
bar: 'bar 5',
metric: 5,
metric__contribution: 5 / 15, // 33.3%
},
],
},
],
theme: supersetTheme,
});
it('generates Other category', () => {
const chartProps = getChartProps({
threshold_for_other: 20,
});
const transformed = transformProps(chartProps as EchartsPieChartProps);
const series = transformed.echartOptions.series as PieSeriesOption[];
const data = series[0].data as PieChartDataItem[];
expect(data).toHaveLength(4);
expect(data[0].value).toBe(3);
expect(data[1].value).toBe(4);
expect(data[2].value).toBe(5);
expect(data[3].value).toBe(1 + 2);
expect(data[3].name).toBe('Other');
expect(data[3].isOther).toBe(true);
});
});

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
/* eslint-disable import/no-extraneous-dependencies */
import { import {
useCallback, useCallback,
useRef, useRef,
@@ -24,6 +25,7 @@ import {
MutableRefObject, MutableRefObject,
CSSProperties, CSSProperties,
DragEvent, DragEvent,
useEffect,
} from 'react'; } from 'react';
import { import {
@@ -39,8 +41,9 @@ import {
Row, Row,
} from 'react-table'; } from 'react-table';
import { matchSorter, rankings } from 'match-sorter'; import { matchSorter, rankings } from 'match-sorter';
import { typedMemo, usePrevious } from '@superset-ui/core'; import { styled, typedMemo, usePrevious } from '@superset-ui/core';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { Space } from 'antd';
import GlobalFilter, { GlobalFilterProps } from './components/GlobalFilter'; import GlobalFilter, { GlobalFilterProps } from './components/GlobalFilter';
import SelectPageSize, { import SelectPageSize, {
SelectPageSizeProps, SelectPageSizeProps,
@@ -50,6 +53,8 @@ import SimplePagination from './components/Pagination';
import useSticky from './hooks/useSticky'; import useSticky from './hooks/useSticky';
import { PAGE_SIZE_OPTIONS } from '../consts'; import { PAGE_SIZE_OPTIONS } from '../consts';
import { sortAlphanumericCaseInsensitive } from './utils/sortAlphanumericCaseInsensitive'; import { sortAlphanumericCaseInsensitive } from './utils/sortAlphanumericCaseInsensitive';
import { SearchOption, SortByItem } from '../types';
import SearchSelectDropdown from './components/SearchSelectDropdown';
export interface DataTableProps<D extends object> extends TableOptions<D> { export interface DataTableProps<D extends object> extends TableOptions<D> {
tableClassName?: string; tableClassName?: string;
@@ -62,7 +67,12 @@ export interface DataTableProps<D extends object> extends TableOptions<D> {
height?: string | number; height?: string | number;
serverPagination?: boolean; serverPagination?: boolean;
onServerPaginationChange: (pageNumber: number, pageSize: number) => void; onServerPaginationChange: (pageNumber: number, pageSize: number) => void;
serverPaginationData: { pageSize?: number; currentPage?: number }; serverPaginationData: {
pageSize?: number;
currentPage?: number;
sortBy?: SortByItem[];
searchColumn?: string;
};
pageSize?: number; pageSize?: number;
noResults?: string | ((filterString: string) => ReactNode); noResults?: string | ((filterString: string) => ReactNode);
sticky?: boolean; sticky?: boolean;
@@ -71,6 +81,14 @@ export interface DataTableProps<D extends object> extends TableOptions<D> {
onColumnOrderChange: () => void; onColumnOrderChange: () => void;
renderGroupingHeaders?: () => JSX.Element; renderGroupingHeaders?: () => JSX.Element;
renderTimeComparisonDropdown?: () => JSX.Element; renderTimeComparisonDropdown?: () => JSX.Element;
handleSortByChange: (sortBy: SortByItem[]) => void;
sortByFromParent: SortByItem[];
manualSearch?: boolean;
onSearchChange?: (searchText: string) => void;
initialSearchText?: string;
searchInputId?: string;
onSearchColChange: (searchCol: string) => void;
searchOptions: SearchOption[];
} }
export interface RenderHTMLCellProps extends HTMLProps<HTMLTableCellElement> { export interface RenderHTMLCellProps extends HTMLProps<HTMLTableCellElement> {
@@ -81,6 +99,24 @@ const sortTypes = {
alphanumeric: sortAlphanumericCaseInsensitive, alphanumeric: sortAlphanumericCaseInsensitive,
}; };
const StyledSpace = styled(Space)`
display: flex;
justify-content: flex-end;
.search-select-container {
display: flex;
}
.search-by-label {
align-self: center;
margin-right: 4px;
}
`;
const StyledRow = styled.div`
display: flex;
`;
// Be sure to pass our updateMyData and the skipReset option // Be sure to pass our updateMyData and the skipReset option
export default typedMemo(function DataTable<D extends object>({ export default typedMemo(function DataTable<D extends object>({
tableClassName, tableClassName,
@@ -105,6 +141,14 @@ export default typedMemo(function DataTable<D extends object>({
onColumnOrderChange, onColumnOrderChange,
renderGroupingHeaders, renderGroupingHeaders,
renderTimeComparisonDropdown, renderTimeComparisonDropdown,
handleSortByChange,
sortByFromParent = [],
manualSearch = false,
onSearchChange,
initialSearchText,
searchInputId,
onSearchColChange,
searchOptions,
...moreUseTableOptions ...moreUseTableOptions
}: DataTableProps<D>): JSX.Element { }: DataTableProps<D>): JSX.Element {
const tableHooks: PluginHook<D>[] = [ const tableHooks: PluginHook<D>[] = [
@@ -115,6 +159,7 @@ export default typedMemo(function DataTable<D extends object>({
doSticky ? useSticky : [], doSticky ? useSticky : [],
hooks || [], hooks || [],
].flat(); ].flat();
const columnNames = Object.keys(data?.[0] || {}); const columnNames = Object.keys(data?.[0] || {});
const previousColumnNames = usePrevious(columnNames); const previousColumnNames = usePrevious(columnNames);
const resultsSize = serverPagination ? rowCount : data.length; const resultsSize = serverPagination ? rowCount : data.length;
@@ -127,7 +172,8 @@ export default typedMemo(function DataTable<D extends object>({
...initialState_, ...initialState_,
// zero length means all pages, the `usePagination` plugin does not // zero length means all pages, the `usePagination` plugin does not
// understand pageSize = 0 // understand pageSize = 0
sortBy: sortByRef.current, // sortBy: sortByRef.current,
sortBy: serverPagination ? sortByFromParent : sortByRef.current,
pageSize: initialPageSize > 0 ? initialPageSize : resultsSize || 10, pageSize: initialPageSize > 0 ? initialPageSize : resultsSize || 10,
}; };
const defaultWrapperRef = useRef<HTMLDivElement>(null); const defaultWrapperRef = useRef<HTMLDivElement>(null);
@@ -188,7 +234,13 @@ export default typedMemo(function DataTable<D extends object>({
wrapStickyTable, wrapStickyTable,
setColumnOrder, setColumnOrder,
allColumns, allColumns,
state: { pageIndex, pageSize, globalFilter: filterValue, sticky = {} }, state: {
pageIndex,
pageSize,
globalFilter: filterValue,
sticky = {},
sortBy,
},
} = useTable<D>( } = useTable<D>(
{ {
columns, columns,
@@ -198,10 +250,46 @@ export default typedMemo(function DataTable<D extends object>({
globalFilter: defaultGlobalFilter, globalFilter: defaultGlobalFilter,
sortTypes, sortTypes,
autoResetSortBy: !isEqual(columnNames, previousColumnNames), autoResetSortBy: !isEqual(columnNames, previousColumnNames),
manualSortBy: !!serverPagination,
...moreUseTableOptions, ...moreUseTableOptions,
}, },
...tableHooks, ...tableHooks,
); );
const handleSearchChange = useCallback(
(query: string) => {
if (manualSearch && onSearchChange) {
onSearchChange(query);
} else {
setGlobalFilter(query);
}
},
[manualSearch, onSearchChange, setGlobalFilter],
);
// updating the sort by to the own State of table viz
useEffect(() => {
const serverSortBy = serverPaginationData?.sortBy || [];
if (serverPagination && !isEqual(sortBy, serverSortBy)) {
if (Array.isArray(sortBy) && sortBy.length > 0) {
const [sortByItem] = sortBy;
const matchingColumn = columns.find(col => col?.id === sortByItem?.id);
if (matchingColumn && 'columnKey' in matchingColumn) {
const sortByWithColumnKey: SortByItem = {
...sortByItem,
key: (matchingColumn as { columnKey: string }).columnKey,
};
handleSortByChange([sortByWithColumnKey]);
}
} else {
handleSortByChange([]);
}
}
}, [sortBy]);
// make setPageSize accept 0 // make setPageSize accept 0
const setPageSize = (size: number) => { const setPageSize = (size: number) => {
if (serverPagination) { if (serverPagination) {
@@ -355,6 +443,7 @@ export default typedMemo(function DataTable<D extends object>({
resultOnPageChange = (pageNumber: number) => resultOnPageChange = (pageNumber: number) =>
onServerPaginationChange(pageNumber, serverPageSize); onServerPaginationChange(pageNumber, serverPageSize);
} }
return ( return (
<div <div
ref={wrapperRef} ref={wrapperRef}
@@ -362,9 +451,9 @@ export default typedMemo(function DataTable<D extends object>({
> >
{hasGlobalControl ? ( {hasGlobalControl ? (
<div ref={globalControlRef} className="form-inline dt-controls"> <div ref={globalControlRef} className="form-inline dt-controls">
<div className="row"> <StyledRow className="row">
<div <div
className={renderTimeComparisonDropdown ? 'col-sm-5' : 'col-sm-6'} className={renderTimeComparisonDropdown ? 'col-sm-4' : 'col-sm-5'}
> >
{hasPagination ? ( {hasPagination ? (
<SelectPageSize <SelectPageSize
@@ -381,16 +470,35 @@ export default typedMemo(function DataTable<D extends object>({
) : null} ) : null}
</div> </div>
{searchInput ? ( {searchInput ? (
<div className="col-sm-6"> <StyledSpace
className={
renderTimeComparisonDropdown ? 'col-sm-7' : 'col-sm-8'
}
>
{serverPagination && (
<div className="search-select-container">
<span className="search-by-label">Search by: </span>
<SearchSelectDropdown
searchOptions={searchOptions}
value={serverPaginationData?.searchColumn || ''}
onChange={onSearchColChange}
/>
</div>
)}
<GlobalFilter<D> <GlobalFilter<D>
searchInput={ searchInput={
typeof searchInput === 'boolean' ? undefined : searchInput typeof searchInput === 'boolean' ? undefined : searchInput
} }
preGlobalFilteredRows={preGlobalFilteredRows} preGlobalFilteredRows={preGlobalFilteredRows}
setGlobalFilter={setGlobalFilter} setGlobalFilter={
filterValue={filterValue} manualSearch ? handleSearchChange : setGlobalFilter
}
filterValue={manualSearch ? initialSearchText : filterValue}
id={searchInputId}
serverPagination={!!serverPagination}
rowCount={rowCount}
/> />
</div> </StyledSpace>
) : null} ) : null}
{renderTimeComparisonDropdown ? ( {renderTimeComparisonDropdown ? (
<div <div
@@ -400,7 +508,7 @@ export default typedMemo(function DataTable<D extends object>({
{renderTimeComparisonDropdown()} {renderTimeComparisonDropdown()}
</div> </div>
) : null} ) : null}
</div> </StyledRow>
</div> </div>
) : null} ) : null}
{wrapStickyTable ? wrapStickyTable(renderTable) : renderTable()} {wrapStickyTable ? wrapStickyTable(renderTable) : renderTable()}

View File

@@ -16,7 +16,13 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { memo, ComponentType, ChangeEventHandler } from 'react'; import {
memo,
ComponentType,
ChangeEventHandler,
useRef,
useEffect,
} from 'react';
import { Row, FilterValue } from 'react-table'; import { Row, FilterValue } from 'react-table';
import useAsyncState from '../utils/useAsyncState'; import useAsyncState from '../utils/useAsyncState';
@@ -24,8 +30,12 @@ export interface SearchInputProps {
count: number; count: number;
value: string; value: string;
onChange: ChangeEventHandler<HTMLInputElement>; onChange: ChangeEventHandler<HTMLInputElement>;
onBlur?: () => void;
inputRef?: React.RefObject<HTMLInputElement>;
} }
const isSearchFocused = new Map();
export interface GlobalFilterProps<D extends object> { export interface GlobalFilterProps<D extends object> {
preGlobalFilteredRows: Row<D>[]; preGlobalFilteredRows: Row<D>[];
// filter value cannot be `undefined` otherwise React will report component // filter value cannot be `undefined` otherwise React will report component
@@ -33,17 +43,28 @@ export interface GlobalFilterProps<D extends object> {
filterValue: string; filterValue: string;
setGlobalFilter: (filterValue: FilterValue) => void; setGlobalFilter: (filterValue: FilterValue) => void;
searchInput?: ComponentType<SearchInputProps>; searchInput?: ComponentType<SearchInputProps>;
id?: string;
serverPagination: boolean;
rowCount: number;
} }
function DefaultSearchInput({ count, value, onChange }: SearchInputProps) { function DefaultSearchInput({
count,
value,
onChange,
onBlur,
inputRef,
}: SearchInputProps) {
return ( return (
<span className="dt-global-filter"> <span className="dt-global-filter">
Search{' '} Search{' '}
<input <input
ref={inputRef}
className="form-control input-sm" className="form-control input-sm"
placeholder={`${count} records...`} placeholder={`${count} records...`}
value={value} value={value}
onChange={onChange} onChange={onChange}
onBlur={onBlur}
/> />
</span> </span>
); );
@@ -56,8 +77,13 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
filterValue = '', filterValue = '',
searchInput, searchInput,
setGlobalFilter, setGlobalFilter,
id = '',
serverPagination,
rowCount,
}: GlobalFilterProps<D>) { }: GlobalFilterProps<D>) {
const count = preGlobalFilteredRows.length; const count = serverPagination ? rowCount : preGlobalFilteredRows.length;
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useAsyncState( const [value, setValue] = useAsyncState(
filterValue, filterValue,
(newValue: string) => { (newValue: string) => {
@@ -66,17 +92,37 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
200, 200,
); );
// Preserve focus during server-side filtering to maintain a better user experience
useEffect(() => {
if (
serverPagination &&
isSearchFocused.get(id) &&
document.activeElement !== inputRef.current
) {
inputRef.current?.focus();
}
}, [value, serverPagination]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const target = e.target as HTMLInputElement;
e.preventDefault();
isSearchFocused.set(id, true);
setValue(target.value);
};
const handleBlur = () => {
isSearchFocused.set(id, false);
};
const SearchInput = searchInput || DefaultSearchInput; const SearchInput = searchInput || DefaultSearchInput;
return ( return (
<SearchInput <SearchInput
count={count} count={count}
value={value} value={value}
onChange={e => { inputRef={inputRef}
const target = e.target as HTMLInputElement; onChange={handleChange}
e.preventDefault(); onBlur={handleBlur}
setValue(target.value);
}}
/> />
); );
}); });

View File

@@ -0,0 +1,53 @@
/**
* 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.
*/
/* eslint-disable import/no-extraneous-dependencies */
import { styled } from '@superset-ui/core';
import { Select } from 'antd';
import { SearchOption } from '../../types';
const StyledSelect = styled(Select)`
width: 120px;
margin-right: 8px;
`;
interface SearchSelectDropdownProps {
/** The currently selected search column value */
value?: string;
/** Callback triggered when a new search column is selected */
onChange: (searchCol: string) => void;
/** Available search column options to populate the dropdown */
searchOptions: SearchOption[];
}
function SearchSelectDropdown({
value,
onChange,
searchOptions,
}: SearchSelectDropdownProps) {
return (
<StyledSelect
className="search-select"
value={value || (searchOptions?.[0]?.value ?? '')}
options={searchOptions}
onChange={onChange}
/>
);
}
export default SearchSelectDropdown;

View File

@@ -115,3 +115,11 @@ declare module 'react-table' {
extends UseTableHooks<D>, extends UseTableHooks<D>,
UseSortByHooks<D> {} UseSortByHooks<D> {}
} }
interface TableOwnState {
currentPage?: number;
pageSize?: number;
sortColumn?: string;
sortOrder?: 'asc' | 'desc';
searchText?: string;
}

View File

@@ -18,6 +18,7 @@
*/ */
import { SetDataMaskHook } from '@superset-ui/core'; import { SetDataMaskHook } from '@superset-ui/core';
import { TableOwnState } from '../types/react-table';
export const updateExternalFormData = ( export const updateExternalFormData = (
setDataMask: SetDataMaskHook = () => {}, setDataMask: SetDataMaskHook = () => {},
@@ -30,3 +31,11 @@ export const updateExternalFormData = (
pageSize, pageSize,
}, },
}); });
export const updateTableOwnState = (
setDataMask: SetDataMaskHook = () => {},
modifiedOwnState: TableOwnState,
) =>
setDataMask({
ownState: modifiedOwnState,
});

View File

@@ -24,6 +24,7 @@ import {
useState, useState,
MouseEvent, MouseEvent,
KeyboardEvent as ReactKeyboardEvent, KeyboardEvent as ReactKeyboardEvent,
useEffect,
} from 'react'; } from 'react';
import { import {
@@ -61,10 +62,12 @@ import {
PlusCircleOutlined, PlusCircleOutlined,
TableOutlined, TableOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { isEmpty } from 'lodash'; import { debounce, isEmpty, isEqual } from 'lodash';
import { import {
ColorSchemeEnum, ColorSchemeEnum,
DataColumnMeta, DataColumnMeta,
SearchOption,
SortByItem,
TableChartTransformedProps, TableChartTransformedProps,
} from './types'; } from './types';
import DataTable, { import DataTable, {
@@ -76,8 +79,8 @@ import DataTable, {
import Styles from './Styles'; import Styles from './Styles';
import { formatColumnValue } from './utils/formatValue'; import { formatColumnValue } from './utils/formatValue';
import { PAGE_SIZE_OPTIONS } from './consts'; import { PAGE_SIZE_OPTIONS, SERVER_PAGE_SIZE_OPTIONS } from './consts';
import { updateExternalFormData } from './DataTable/utils/externalAPIs'; import { updateTableOwnState } from './DataTable/utils/externalAPIs';
import getScrollBarSize from './DataTable/utils/getScrollBarSize'; import getScrollBarSize from './DataTable/utils/getScrollBarSize';
type ValueRange = [number, number]; type ValueRange = [number, number];
@@ -176,20 +179,26 @@ function SortIcon<D extends object>({ column }: { column: ColumnInstance<D> }) {
return sortIcon; return sortIcon;
} }
function SearchInput({ count, value, onChange }: SearchInputProps) { const SearchInput = ({
return ( count,
<span className="dt-global-filter"> value,
{t('Search')}{' '} onChange,
<input onBlur,
aria-label={t('Search %s records', count)} inputRef,
className="form-control input-sm" }: SearchInputProps) => (
placeholder={tn('search.num_records', count)} <span className="dt-global-filter">
value={value} {t('Search')}{' '}
onChange={onChange} <input
/> ref={inputRef}
</span> aria-label={t('Search %s records', count)}
); className="form-control input-sm"
} placeholder={tn('search.num_records', count)}
value={value}
onChange={onChange}
onBlur={onBlur}
/>
</span>
);
function SelectPageSize({ function SelectPageSize({
options, options,
@@ -267,6 +276,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
isUsingTimeComparison, isUsingTimeComparison,
basicColorFormatters, basicColorFormatters,
basicColorColumnFormatters, basicColorColumnFormatters,
hasServerPageLengthChanged,
serverPageLength,
slice_id,
} = props; } = props;
const comparisonColumns = [ const comparisonColumns = [
{ key: 'all', label: t('Display all') }, { key: 'all', label: t('Display all') },
@@ -294,7 +306,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
// only take relevant page size options // only take relevant page size options
const pageSizeOptions = useMemo(() => { const pageSizeOptions = useMemo(() => {
const getServerPagination = (n: number) => n <= rowCount; const getServerPagination = (n: number) => n <= rowCount;
return PAGE_SIZE_OPTIONS.filter(([n]) => return (
serverPagination ? SERVER_PAGE_SIZE_OPTIONS : PAGE_SIZE_OPTIONS
).filter(([n]) =>
serverPagination ? getServerPagination(n) : n <= 2 * data.length, serverPagination ? getServerPagination(n) : n <= 2 * data.length,
) as SizeOption[]; ) as SizeOption[];
}, [data.length, rowCount, serverPagination]); }, [data.length, rowCount, serverPagination]);
@@ -679,7 +693,12 @@ export default function TableChart<D extends DataRecord = DataRecord>(
); );
const getColumnConfigs = useCallback( const getColumnConfigs = useCallback(
(column: DataColumnMeta, i: number): ColumnWithLooseAccessor<D> => { (
column: DataColumnMeta,
i: number,
): ColumnWithLooseAccessor<D> & {
columnKey: string;
} => {
const { const {
key, key,
label: originalLabel, label: originalLabel,
@@ -766,6 +785,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
// must use custom accessor to allow `.` in column names // must use custom accessor to allow `.` in column names
// typing is incorrect in current version of `@types/react-table` // typing is incorrect in current version of `@types/react-table`
// so we ask TS not to check. // so we ask TS not to check.
columnKey: key,
accessor: ((datum: D) => datum[key]) as never, accessor: ((datum: D) => datum[key]) as never,
Cell: ({ value, row }: { value: DataRecordValue; row: Row<D> }) => { Cell: ({ value, row }: { value: DataRecordValue; row: Row<D> }) => {
const [isHtml, text] = formatColumnValue(column, value); const [isHtml, text] = formatColumnValue(column, value);
@@ -1058,13 +1078,50 @@ export default function TableChart<D extends DataRecord = DataRecord>(
[visibleColumnsMeta, getColumnConfigs], [visibleColumnsMeta, getColumnConfigs],
); );
const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]);
useEffect(() => {
const options = (
columns as unknown as ColumnWithLooseAccessor &
{
columnKey: string;
sortType?: string;
}[]
)
.filter(col => col?.sortType === 'alphanumeric')
.map(column => ({
value: column.columnKey,
label: column.columnKey,
}));
if (!isEqual(options, searchOptions)) {
setSearchOptions(options || []);
}
}, [columns]);
const handleServerPaginationChange = useCallback( const handleServerPaginationChange = useCallback(
(pageNumber: number, pageSize: number) => { (pageNumber: number, pageSize: number) => {
updateExternalFormData(setDataMask, pageNumber, pageSize); const modifiedOwnState = {
...serverPaginationData,
currentPage: pageNumber,
pageSize,
};
updateTableOwnState(setDataMask, modifiedOwnState);
}, },
[setDataMask], [setDataMask],
); );
useEffect(() => {
if (hasServerPageLengthChanged) {
const modifiedOwnState = {
...serverPaginationData,
currentPage: 0,
pageSize: serverPageLength,
};
updateTableOwnState(setDataMask, modifiedOwnState);
}
}, []);
const handleSizeChange = useCallback( const handleSizeChange = useCallback(
({ width, height }: { width: number; height: number }) => { ({ width, height }: { width: number; height: number }) => {
setTableSize({ width, height }); setTableSize({ width, height });
@@ -1100,6 +1157,42 @@ export default function TableChart<D extends DataRecord = DataRecord>(
const { width: widthFromState, height: heightFromState } = tableSize; const { width: widthFromState, height: heightFromState } = tableSize;
const handleSortByChange = useCallback(
(sortBy: SortByItem[]) => {
if (!serverPagination) return;
const modifiedOwnState = {
...serverPaginationData,
sortBy,
};
updateTableOwnState(setDataMask, modifiedOwnState);
},
[setDataMask, serverPagination],
);
const handleSearch = (searchText: string) => {
const modifiedOwnState = {
...(serverPaginationData || {}),
searchColumn:
serverPaginationData?.searchColumn || searchOptions[0]?.value,
searchText,
currentPage: 0, // Reset to first page when searching
};
updateTableOwnState(setDataMask, modifiedOwnState);
};
const debouncedSearch = debounce(handleSearch, 800);
const handleChangeSearchCol = (searchCol: string) => {
if (!isEqual(searchCol, serverPaginationData?.searchColumn)) {
const modifiedOwnState = {
...(serverPaginationData || {}),
searchColumn: searchCol,
searchText: '',
};
updateTableOwnState(setDataMask, modifiedOwnState);
}
};
return ( return (
<Styles> <Styles>
<DataTable<D> <DataTable<D>
@@ -1115,6 +1208,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
serverPagination={serverPagination} serverPagination={serverPagination}
onServerPaginationChange={handleServerPaginationChange} onServerPaginationChange={handleServerPaginationChange}
onColumnOrderChange={() => setColumnOrderToggle(!columnOrderToggle)} onColumnOrderChange={() => setColumnOrderToggle(!columnOrderToggle)}
initialSearchText={serverPaginationData?.searchText || ''}
sortByFromParent={serverPaginationData?.sortBy || []}
searchInputId={`${slice_id}-search`}
// 9 page items in > 340px works well even for 100+ pages // 9 page items in > 340px works well even for 100+ pages
maxPageItemCount={width > 340 ? 9 : 7} maxPageItemCount={width > 340 ? 9 : 7}
noResults={getNoResultsMessage} noResults={getNoResultsMessage}
@@ -1128,6 +1224,11 @@ export default function TableChart<D extends DataRecord = DataRecord>(
renderTimeComparisonDropdown={ renderTimeComparisonDropdown={
isUsingTimeComparison ? renderTimeComparisonDropdown : undefined isUsingTimeComparison ? renderTimeComparisonDropdown : undefined
} }
handleSortByChange={handleSortByChange}
onSearchColChange={handleChangeSearchCol}
manualSearch={serverPagination}
onSearchChange={debouncedSearch}
searchOptions={searchOptions}
/> />
</Styles> </Styles>
); );

View File

@@ -22,6 +22,7 @@ import {
ensureIsArray, ensureIsArray,
getMetricLabel, getMetricLabel,
isPhysicalColumn, isPhysicalColumn,
QueryFormOrderBy,
QueryMode, QueryMode,
QueryObject, QueryObject,
removeDuplicates, removeDuplicates,
@@ -34,7 +35,7 @@ import {
} from '@superset-ui/chart-controls'; } from '@superset-ui/chart-controls';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { TableChartFormData } from './types'; import { TableChartFormData } from './types';
import { updateExternalFormData } from './DataTable/utils/externalAPIs'; import { updateTableOwnState } from './DataTable/utils/externalAPIs';
/** /**
* Infer query mode from form data. If `all_columns` is set, then raw records mode, * Infer query mode from form data. If `all_columns` is set, then raw records mode,
@@ -191,18 +192,40 @@ const buildQuery: BuildQuery<TableChartFormData> = (
const moreProps: Partial<QueryObject> = {}; const moreProps: Partial<QueryObject> = {};
const ownState = options?.ownState ?? {}; const ownState = options?.ownState ?? {};
if (formDataCopy.server_pagination) { // Build Query flag to check if its for either download as csv, excel or json
moreProps.row_limit = const isDownloadQuery =
ownState.pageSize ?? formDataCopy.server_page_length; ['csv', 'xlsx'].includes(formData?.result_format || '') ||
moreProps.row_offset = (formData?.result_format === 'json' &&
(ownState.currentPage ?? 0) * (ownState.pageSize ?? 0); formData?.result_type === 'results');
if (isDownloadQuery) {
moreProps.row_limit = Number(formDataCopy.row_limit) || 0;
moreProps.row_offset = 0;
}
if (!isDownloadQuery && formDataCopy.server_pagination) {
const pageSize = ownState.pageSize ?? formDataCopy.server_page_length;
const currentPage = ownState.currentPage ?? 0;
moreProps.row_limit = pageSize;
moreProps.row_offset = currentPage * pageSize;
}
// getting sort by in case of server pagination from own state
let sortByFromOwnState: QueryFormOrderBy[] | undefined;
if (Array.isArray(ownState?.sortBy) && ownState?.sortBy.length > 0) {
const sortByItem = ownState?.sortBy[0];
sortByFromOwnState = [[sortByItem?.key, !sortByItem?.desc]];
} }
let queryObject = { let queryObject = {
...baseQueryObject, ...baseQueryObject,
columns, columns,
extras, extras,
orderby, orderby:
formData.server_pagination && sortByFromOwnState
? sortByFromOwnState
: orderby,
metrics, metrics,
post_processing: postProcessing, post_processing: postProcessing,
time_offsets: timeOffsets, time_offsets: timeOffsets,
@@ -216,11 +239,12 @@ const buildQuery: BuildQuery<TableChartFormData> = (
JSON.stringify(queryObject.filters) JSON.stringify(queryObject.filters)
) { ) {
queryObject = { ...queryObject, row_offset: 0 }; queryObject = { ...queryObject, row_offset: 0 };
updateExternalFormData( const modifiedOwnState = {
options?.hooks?.setDataMask, ...(options?.ownState || {}),
0, currentPage: 0,
queryObject.row_limit ?? 0, pageSize: queryObject.row_limit ?? 0,
); };
updateTableOwnState(options?.hooks?.setDataMask, modifiedOwnState);
} }
// Because we use same buildQuery for all table on the page we need split them by id // Because we use same buildQuery for all table on the page we need split them by id
options?.hooks?.setCachedChanges({ options?.hooks?.setCachedChanges({
@@ -252,12 +276,32 @@ const buildQuery: BuildQuery<TableChartFormData> = (
} }
if (formData.server_pagination) { if (formData.server_pagination) {
// Add search filter if search text exists
if (ownState.searchText && ownState?.searchColumn) {
queryObject = {
...queryObject,
filters: [
...(queryObject.filters || []),
{
col: ownState?.searchColumn,
op: 'ILIKE',
val: `${ownState.searchText}%`,
},
],
};
}
}
// Now since row limit control is always visible even
// in case of server pagination
// we must use row limit from form data
if (formData.server_pagination && !isDownloadQuery) {
return [ return [
{ ...queryObject }, { ...queryObject },
{ {
...queryObject, ...queryObject,
time_offsets: [], time_offsets: [],
row_limit: 0, row_limit: Number(formData?.row_limit) ?? 0,
row_offset: 0, row_offset: 0,
post_processing: [], post_processing: [],
is_rowcount: true, is_rowcount: true,

View File

@@ -30,3 +30,7 @@ export const PAGE_SIZE_OPTIONS = formatSelectOptions<number>([
100, 100,
200, 200,
]); ]);
export const SERVER_PAGE_SIZE_OPTIONS = formatSelectOptions<number>([
10, 20, 50, 100, 200,
]);

View File

@@ -28,7 +28,10 @@ import {
ControlStateMapping, ControlStateMapping,
D3_TIME_FORMAT_OPTIONS, D3_TIME_FORMAT_OPTIONS,
Dataset, Dataset,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
defineSavedMetrics, defineSavedMetrics,
formatSelectOptions,
getStandardizedControls, getStandardizedControls,
QueryModeLabel, QueryModeLabel,
sections, sections,
@@ -40,15 +43,18 @@ import {
getMetricLabel, getMetricLabel,
isAdhocColumn, isAdhocColumn,
isPhysicalColumn, isPhysicalColumn,
legacyValidateInteger,
QueryFormColumn, QueryFormColumn,
QueryFormMetric, QueryFormMetric,
QueryMode, QueryMode,
SMART_DATE_ID, SMART_DATE_ID,
t, t,
validateMaxValue,
validateServerPagination,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { isEmpty, last } from 'lodash'; import { isEmpty, last } from 'lodash';
import { PAGE_SIZE_OPTIONS } from './consts'; import { PAGE_SIZE_OPTIONS, SERVER_PAGE_SIZE_OPTIONS } from './consts';
import { ColorSchemeEnum } from './types'; import { ColorSchemeEnum } from './types';
function getQueryMode(controls: ControlStateMapping): QueryMode { function getQueryMode(controls: ControlStateMapping): QueryMode {
@@ -188,6 +194,15 @@ const processComparisonColumns = (columns: any[], suffix: string) =>
}) })
.flat(); .flat();
/*
Options for row limit control
*/
export const ROW_LIMIT_OPTIONS_TABLE = [
10, 50, 100, 250, 500, 1000, 5000, 10000, 50000, 100000, 150000, 200000,
250000, 300000, 350000, 400000, 450000, 500000,
];
const config: ControlPanelConfig = { const config: ControlPanelConfig = {
controlPanelSections: [ controlPanelSections: [
{ {
@@ -328,6 +343,26 @@ const config: ControlPanelConfig = {
}, },
}, },
], ],
[
{
name: 'order_desc',
config: {
type: 'CheckboxControl',
label: t('Sort descending'),
default: true,
description: t(
'If enabled, this control sorts the results/values descending, otherwise it sorts the results ascending.',
),
visibility: ({ controls }: ControlPanelsContainerProps) => {
const hasSortMetric = Boolean(
controls?.timeseries_limit_metric?.value,
);
return hasSortMetric && isAggMode({ controls });
},
resetOnHide: false,
},
},
],
[ [
{ {
name: 'server_pagination', name: 'server_pagination',
@@ -342,14 +377,6 @@ const config: ControlPanelConfig = {
}, },
], ],
[ [
{
name: 'row_limit',
override: {
default: 1000,
visibility: ({ controls }: ControlPanelsContainerProps) =>
!controls?.server_pagination?.value,
},
},
{ {
name: 'server_page_length', name: 'server_page_length',
config: { config: {
@@ -357,7 +384,7 @@ const config: ControlPanelConfig = {
freeForm: true, freeForm: true,
label: t('Server Page Length'), label: t('Server Page Length'),
default: 10, default: 10,
choices: PAGE_SIZE_OPTIONS, choices: SERVER_PAGE_SIZE_OPTIONS,
description: t('Rows per page, 0 means no pagination'), description: t('Rows per page, 0 means no pagination'),
visibility: ({ controls }: ControlPanelsContainerProps) => visibility: ({ controls }: ControlPanelsContainerProps) =>
Boolean(controls?.server_pagination?.value), Boolean(controls?.server_pagination?.value),
@@ -366,16 +393,43 @@ const config: ControlPanelConfig = {
], ],
[ [
{ {
name: 'order_desc', name: 'row_limit',
config: { config: {
type: 'CheckboxControl', type: 'SelectControl',
label: t('Sort descending'), freeForm: true,
default: true, label: t('Row limit'),
clearable: false,
mapStateToProps: state => ({
maxValue: state?.common?.conf?.TABLE_VIZ_MAX_ROW_SERVER,
server_pagination: state?.form_data?.server_pagination,
maxValueWithoutServerPagination:
state?.common?.conf?.SQL_MAX_ROW,
}),
validators: [
legacyValidateInteger,
(v, state) =>
validateMaxValue(
v,
state?.maxValue || DEFAULT_MAX_ROW_TABLE_SERVER,
),
(v, state) =>
validateServerPagination(
v,
state?.server_pagination,
state?.maxValueWithoutServerPagination || DEFAULT_MAX_ROW,
state?.maxValue || DEFAULT_MAX_ROW_TABLE_SERVER,
),
],
// Re run the validations when this control value
validationDependancies: ['server_pagination'],
default: 10000,
choices: formatSelectOptions(ROW_LIMIT_OPTIONS_TABLE),
description: t( description: t(
'If enabled, this control sorts the results/values descending, otherwise it sorts the results ascending.', 'Limits the number of the rows that are computed in the query that is the source of the data used for this chart.',
), ),
visibility: isAggMode, },
resetOnHide: false, override: {
default: 1000,
}, },
}, },
], ],

View File

@@ -90,6 +90,15 @@ const processDataRecords = memoizeOne(function processDataRecords(
return data; return data;
}); });
// Create a map to store cached values per slice
const sliceCache = new Map<
number,
{
cachedServerLength: number;
passedColumns?: DataColumnMeta[];
}
>();
const calculateDifferences = ( const calculateDifferences = (
originalValue: number, originalValue: number,
comparisonValue: number, comparisonValue: number,
@@ -480,6 +489,7 @@ const transformProps = (
comparison_color_enabled: comparisonColorEnabled = false, comparison_color_enabled: comparisonColorEnabled = false,
comparison_color_scheme: comparisonColorScheme = ColorSchemeEnum.Green, comparison_color_scheme: comparisonColorScheme = ColorSchemeEnum.Green,
comparison_type, comparison_type,
slice_id,
} = formData; } = formData;
const isUsingTimeComparison = const isUsingTimeComparison =
!isEmpty(time_compare) && !isEmpty(time_compare) &&
@@ -675,6 +685,26 @@ const transformProps = (
conditionalFormatting, conditionalFormatting,
); );
// Get cached values for this slice
const cachedValues = sliceCache.get(slice_id);
let hasServerPageLengthChanged = false;
if (
cachedValues?.cachedServerLength !== undefined &&
cachedValues.cachedServerLength !== serverPageLength
) {
hasServerPageLengthChanged = true;
}
// Update cache with new values
sliceCache.set(slice_id, {
cachedServerLength: serverPageLength,
passedColumns:
Array.isArray(passedColumns) && passedColumns?.length > 0
? passedColumns
: cachedValues?.passedColumns,
});
const startDateOffset = chartProps.rawFormData?.start_date_offset; const startDateOffset = chartProps.rawFormData?.start_date_offset;
return { return {
height, height,
@@ -682,7 +712,10 @@ const transformProps = (
isRawRecords: queryMode === QueryMode.Raw, isRawRecords: queryMode === QueryMode.Raw,
data: passedData, data: passedData,
totals, totals,
columns: passedColumns, columns:
Array.isArray(passedColumns) && passedColumns?.length > 0
? passedColumns
: cachedValues?.passedColumns || [],
serverPagination, serverPagination,
metrics, metrics,
percentMetrics, percentMetrics,
@@ -697,7 +730,9 @@ const transformProps = (
includeSearch, includeSearch,
rowCount, rowCount,
pageSize: serverPagination pageSize: serverPagination
? serverPageLength ? serverPaginationData?.pageSize
? serverPaginationData?.pageSize
: serverPageLength
: getPageSize(pageLength, data.length, columns.length), : getPageSize(pageLength, data.length, columns.length),
filters: filterState.filters, filters: filterState.filters,
emitCrossFilters, emitCrossFilters,
@@ -711,6 +746,9 @@ const transformProps = (
basicColorFormatters, basicColorFormatters,
startDateOffset, startDateOffset,
basicColorColumnFormatters, basicColorColumnFormatters,
hasServerPageLengthChanged,
serverPageLength,
slice_id,
}; };
}; };

View File

@@ -114,13 +114,32 @@ export type BasicColorFormatterType = {
mainArrow: string; mainArrow: string;
}; };
export type SortByItem = {
id: string;
key: string;
desc?: boolean;
};
export type SearchOption = {
value: string;
label: string;
};
export interface ServerPaginationData {
pageSize?: number;
currentPage?: number;
sortBy?: SortByItem[];
searchText?: string;
searchColumn?: string;
}
export interface TableChartTransformedProps<D extends DataRecord = DataRecord> { export interface TableChartTransformedProps<D extends DataRecord = DataRecord> {
timeGrain?: TimeGranularity; timeGrain?: TimeGranularity;
height: number; height: number;
width: number; width: number;
rowCount?: number; rowCount?: number;
serverPagination: boolean; serverPagination: boolean;
serverPaginationData: { pageSize?: number; currentPage?: number }; serverPaginationData: ServerPaginationData;
setDataMask: SetDataMaskHook; setDataMask: SetDataMaskHook;
isRawRecords?: boolean; isRawRecords?: boolean;
data: D[]; data: D[];
@@ -152,6 +171,11 @@ export interface TableChartTransformedProps<D extends DataRecord = DataRecord> {
basicColorFormatters?: { [Key: string]: BasicColorFormatterType }[]; basicColorFormatters?: { [Key: string]: BasicColorFormatterType }[];
basicColorColumnFormatters?: { [Key: string]: BasicColorFormatterType }[]; basicColorColumnFormatters?: { [Key: string]: BasicColorFormatterType }[];
startDateOffset?: string; startDateOffset?: string;
// For explore page to reset the server Pagination data
// if server page length is changed from control panel
hasServerPageLengthChanged: boolean;
serverPageLength: number;
slice_id: number;
} }
export enum ColorSchemeEnum { export enum ColorSchemeEnum {

View File

@@ -325,6 +325,8 @@ const SqlEditor: FC<Props> = ({
const SqlFormExtension = extensionsRegistry.get('sqleditor.extension.form'); const SqlFormExtension = extensionsRegistry.get('sqleditor.extension.form');
const isTempId = (value: unknown): boolean => Number.isNaN(Number(value));
const startQuery = useCallback( const startQuery = useCallback(
(ctasArg = false, ctas_method = CtasEnum.Table) => { (ctasArg = false, ctas_method = CtasEnum.Table) => {
if (!database) { if (!database) {
@@ -915,7 +917,7 @@ const SqlEditor: FC<Props> = ({
)} )}
{isActive && ( {isActive && (
<AceEditorWrapper <AceEditorWrapper
autocomplete={autocompleteEnabled} autocomplete={autocompleteEnabled && !isTempId(queryEditor.id)}
onBlur={onSqlChanged} onBlur={onSqlChanged}
onChange={onSqlChanged} onChange={onSqlChanged}
queryEditorId={queryEditor.id} queryEditorId={queryEditor.id}

View File

@@ -77,21 +77,6 @@
} }
} }
.caret {
border: none;
color: @gray;
&:hover {
color: @gray-darker;
}
&:before {
font-family: 'FontAwesome';
font-size: @font-size-xs;
content: '\f078';
}
}
// Typography ================================================================= // Typography =================================================================
body { body {

View File

@@ -11,37 +11,23 @@
* *
* Unless required by applicable law or agreed to in writing, * Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an * software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
* KIND, either express or implied. See the License for the * OF ANY KIND, either express or implied. See the License for
* specific language governing permissions and limitations * the specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { render, screen } from 'spec/helpers/testing-library'; import { getTooltipHTML } from './Tooltip';
import Tooltip, { getTooltipHTML } from './Tooltip';
test('should render a tooltip', () => { test('getTooltipHTML returns the expected HTML (string inputs)', () => {
const expected = {
title: 'tooltip title',
icon: <div>icon</div>,
body: <div>body</div>,
meta: 'meta',
footer: <div>footer</div>,
};
render(<Tooltip {...expected} />);
expect(screen.getByText(expected.title)).toBeInTheDocument();
expect(screen.getByText(expected.meta)).toBeInTheDocument();
expect(screen.getByText('icon')).toBeInTheDocument();
expect(screen.getByText('body')).toBeInTheDocument();
});
test('returns the tooltip HTML', () => {
const html = getTooltipHTML({ const html = getTooltipHTML({
title: 'tooltip title', title: 'tooltip title',
icon: <div>icon</div>, body: 'body text',
body: <div>body</div>, footer: 'footer note',
meta: 'meta',
footer: <div>footer</div>,
}); });
expect(html).toContain('tooltip-detail');
expect(html).toContain('tooltip title'); expect(html).toContain('tooltip title');
expect(html).toContain('body text');
expect(html).toContain('footer note');
}); });

View File

@@ -16,42 +16,22 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { renderToStaticMarkup } from 'react-dom/server';
import { Tag } from 'src/components'; import DOMPurify from 'dompurify';
type Props = { type Props = {
title: string; title?: string;
icon?: React.ReactNode; body?: string;
body?: React.ReactNode; footer?: string;
meta?: string;
footer?: React.ReactNode;
}; };
export const Tooltip: React.FC<Props> = ({ export function getTooltipHTML({ title, body, footer }: Props): string {
title, const html = `
icon, <div class="tooltip-detail">
body, ${title ? `<div class="tooltip-detail-title">${title}</div>` : ''}
meta, ${body ? `<div class="tooltip-detail-body">${body}</div>` : ''}
footer, ${footer ? `<div class="tooltip-detail-footer">${footer}</div>` : ''}
}) => (
<div className="tooltip-detail">
<div className="tooltip-detail-head">
<div className="tooltip-detail-title">
{icon}
{title}
</div>
{meta && (
<span className="tooltip-detail-meta">
<Tag color="default">{meta}</Tag>
</span>
)}
</div> </div>
{body && <div className="tooltip-detail-body">{body ?? title}</div>} `;
{footer && <div className="tooltip-detail-footer">{footer}</div>} return DOMPurify.sanitize(html);
</div> }
);
export const getTooltipHTML = (props: Props) =>
`${renderToStaticMarkup(<Tooltip {...props} />)}`;
export default Tooltip;

View File

@@ -190,50 +190,37 @@ export default function AsyncAceEditor(
return ( return (
<> <>
<Global <Global
key="ace-tooltip-global"
styles={css` styles={css`
.ace_tooltip { .ace_tooltip {
margin-left: ${supersetTheme.gridUnit * 2}px; all: unset;
padding: 0px; position: fixed;
z-index: 9999;
background: ${supersetTheme.colors.grayscale.light5};
border: 1px solid ${supersetTheme.colors.grayscale.light1}; border: 1px solid ${supersetTheme.colors.grayscale.light1};
padding: ${supersetTheme.gridUnit}px
${supersetTheme.gridUnit * 2}px;
line-height: 1.4;
max-width: 400px;
min-width: 200px;
pointer-events: auto;
font-size: ${supersetTheme.typography.sizes.m}px;
} }
& .tooltip-detail { & .tooltip-detail {
background-color: ${supersetTheme.colors.grayscale.light5};
white-space: pre-wrap;
word-break: break-all;
min-width: ${supersetTheme.gridUnit * 50}px;
max-width: ${supersetTheme.gridUnit * 100}px;
& .tooltip-detail-head {
background-color: ${supersetTheme.colors.grayscale.light4};
color: ${supersetTheme.colors.grayscale.dark1};
display: flex;
column-gap: ${supersetTheme.gridUnit}px;
align-items: baseline;
justify-content: space-between;
}
& .tooltip-detail-title { & .tooltip-detail-title {
display: flex; font-weight: bold;
column-gap: ${supersetTheme.gridUnit}px; font-size: ${supersetTheme.typography.sizes.m}px;
} }
& .tooltip-detail-body { & .tooltip-detail-body {
word-break: break-word; font-size: ${supersetTheme.typography.sizes.s}px;
padding: ${supersetTheme.gridUnit}px;
} }
& .tooltip-detail-head, & .tooltip-detail-head,
& .tooltip-detail-body { & .tooltip-detail-body {
padding: ${supersetTheme.gridUnit}px
${supersetTheme.gridUnit * 2}px;
} }
& .tooltip-detail-footer { & .tooltip-detail-footer {
border-top: 1px ${supersetTheme.colors.grayscale.light2} font-size: ${supersetTheme.typography.sizes.s}px;
solid;
padding: 0 ${supersetTheme.gridUnit * 2}px;
color: ${supersetTheme.colors.grayscale.dark1};
font-size: ${supersetTheme.typography.sizes.xs}px;
}
& .tooltip-detail-meta {
& > .ant-tag {
margin-right: 0px;
}
} }
} }
`} `}

View File

@@ -327,6 +327,10 @@ class ChartRenderer extends Component {
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail) ?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
? { inContextMenu: this.state.inContextMenu } ? { inContextMenu: this.state.inContextMenu }
: {}; : {};
// By pass no result component when server pagination is enabled & the table has a backend search query
const bypassNoResult = !(
formData?.server_pagination && (ownState?.searchText?.length || 0) > 0
);
return ( return (
<> <>
@@ -367,6 +371,7 @@ class ChartRenderer extends Component {
postTransformProps={postTransformProps} postTransformProps={postTransformProps}
emitCrossFilters={emitCrossFilters} emitCrossFilters={emitCrossFilters}
legendState={this.state.legendState} legendState={this.state.legendState}
enableNoResults={bypassNoResult}
{...drillToDetailProps} {...drillToDetailProps}
/> />
</div> </div>

View File

@@ -34,6 +34,7 @@ import {
t, t,
withTheme, withTheme,
getClientErrorObject, getClientErrorObject,
getExtensionsRegistry,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { Select, AsyncSelect, Row, Col } from 'src/components'; import { Select, AsyncSelect, Row, Col } from 'src/components';
import { FormLabel } from 'src/components/Form'; import { FormLabel } from 'src/components/Form';
@@ -53,10 +54,15 @@ import SpatialControl from 'src/explore/components/controls/SpatialControl';
import withToasts from 'src/components/MessageToasts/withToasts'; import withToasts from 'src/components/MessageToasts/withToasts';
import { Icons } from 'src/components/Icons'; import { Icons } from 'src/components/Icons';
import CurrencyControl from 'src/explore/components/controls/CurrencyControl'; import CurrencyControl from 'src/explore/components/controls/CurrencyControl';
import { executeQuery, resetDatabaseState } from 'src/database/actions';
import { connect } from 'react-redux';
import CollectionTable from './CollectionTable'; import CollectionTable from './CollectionTable';
import Fieldset from './Fieldset'; import Fieldset from './Fieldset';
import Field from './Field'; import Field from './Field';
import { fetchSyncedColumns, updateColumns } from './utils'; import { fetchSyncedColumns, updateColumns } from './utils';
import FilterableTable from '../FilterableTable';
const extensionsRegistry = getExtensionsRegistry();
const DatasourceContainer = styled.div` const DatasourceContainer = styled.div`
.change-warning { .change-warning {
@@ -586,6 +592,8 @@ function OwnersSelector({ datasource, onChange }) {
/> />
); );
} }
const ResultTable =
extensionsRegistry.get('sqleditor.extension.resultTable') ?? FilterableTable;
class DatasourceEditor extends PureComponent { class DatasourceEditor extends PureComponent {
constructor(props) { constructor(props) {
@@ -698,6 +706,23 @@ class DatasourceEditor extends PureComponent {
this.validate(this.onChange); this.validate(this.onChange);
} }
async onQueryRun() {
this.props.runQuery({
client_id: this.props.clientId,
database_id: this.state.datasource.database.id,
json: true,
runAsync: false,
catalog: this.state.datasource.catalog,
schema: this.state.datasource.schema,
sql: this.state.datasource.sql,
tmp_table_name: '',
select_as_cta: false,
ctas_method: 'TABLE',
queryLimit: 25,
expand_data: true,
});
}
tableChangeAndSyncMetadata() { tableChangeAndSyncMetadata() {
this.validate(() => { this.validate(() => {
this.syncMetadata(); this.syncMetadata();
@@ -1078,14 +1103,62 @@ class DatasourceEditor extends PureComponent {
<TextAreaControl <TextAreaControl
language="sql" language="sql"
offerEditInModal={false} offerEditInModal={false}
minLines={20} minLines={10}
maxLines={Infinity} maxLines={Infinity}
readOnly={!this.state.isEditMode} readOnly={!this.state.isEditMode}
resize="both" resize="both"
tooltipOptions={sqlTooltipOptions} tooltipOptions={sqlTooltipOptions}
/> />
} }
additionalControl={
<div
css={css`
position: absolute;
right: 0;
top: 0;
z-index: 2;
`}
>
<Button
css={css`
align-self: flex-end;
height: 24px;
padding-left: 6px;
padding-right: 6px;
`}
size="small"
buttonStyle="primary"
onClick={() => {
this.onQueryRun();
}}
>
<Icons.CaretRightFilled
iconSize="s"
css={theme => ({
color: theme.colors.grayscale.light5,
})}
/>
</Button>
</div>
}
errorMessage={
this.props.database?.error && t('Error executing query.')
}
/> />
{this.props.database?.queryResult && (
<ResultTable
data={this.props.database.queryResult.data}
queryId={this.props.database.queryResult.query.id}
orderedColumnKeys={this.props.database.queryResult.columns.map(
col => col.column_name,
)}
height={100}
expandedColumns={
this.props.database.queryResult.expandedColumns
}
allowHTML
/>
)}
</> </>
)} )}
</div> </div>
@@ -1466,6 +1539,10 @@ class DatasourceEditor extends PureComponent {
</DatasourceContainer> </DatasourceContainer>
); );
} }
componentWillUnmount() {
this.props.resetQuery();
}
} }
DatasourceEditor.defaultProps = defaultProps; DatasourceEditor.defaultProps = defaultProps;
@@ -1473,4 +1550,14 @@ DatasourceEditor.propTypes = propTypes;
const DataSourceComponent = withTheme(DatasourceEditor); const DataSourceComponent = withTheme(DatasourceEditor);
export default withToasts(DataSourceComponent); const mapDispatchToProps = dispatch => ({
runQuery: payload => dispatch(executeQuery(payload)),
resetQuery: () => dispatch(resetDatabaseState()),
});
const mapStateToProps = state => ({
test: state.queryApi,
database: state.database,
});
export default withToasts(
connect(mapStateToProps, mapDispatchToProps)(DataSourceComponent),
);

View File

@@ -120,71 +120,83 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
const [isEditing, setIsEditing] = useState<boolean>(false); const [isEditing, setIsEditing] = useState<boolean>(false);
const dialog = useRef<any>(null); const dialog = useRef<any>(null);
const [modal, contextHolder] = Modal.useModal(); const [modal, contextHolder] = Modal.useModal();
const buildPayload = (datasource: Record<string, any>) => ({ const buildPayload = (datasource: Record<string, any>) => {
table_name: datasource.table_name, const payload: Record<string, any> = {
database_id: datasource.database?.id, table_name: datasource.table_name,
sql: datasource.sql, database_id: datasource.database?.id,
filter_select_enabled: datasource.filter_select_enabled, sql: datasource.sql,
fetch_values_predicate: datasource.fetch_values_predicate, filter_select_enabled: datasource.filter_select_enabled,
schema: fetch_values_predicate: datasource.fetch_values_predicate,
datasource.tableSelector?.schema || schema:
datasource.databaseSelector?.schema || datasource.tableSelector?.schema ||
datasource.schema, datasource.databaseSelector?.schema ||
description: datasource.description, datasource.schema,
main_dttm_col: datasource.main_dttm_col, description: datasource.description,
normalize_columns: datasource.normalize_columns, main_dttm_col: datasource.main_dttm_col,
always_filter_main_dttm: datasource.always_filter_main_dttm, normalize_columns: datasource.normalize_columns,
offset: datasource.offset, always_filter_main_dttm: datasource.always_filter_main_dttm,
default_endpoint: datasource.default_endpoint, offset: datasource.offset,
cache_timeout: default_endpoint: datasource.default_endpoint,
datasource.cache_timeout === '' ? null : datasource.cache_timeout, cache_timeout:
is_sqllab_view: datasource.is_sqllab_view, datasource.cache_timeout === '' ? null : datasource.cache_timeout,
template_params: datasource.template_params, is_sqllab_view: datasource.is_sqllab_view,
extra: datasource.extra, template_params: datasource.template_params,
is_managed_externally: datasource.is_managed_externally, extra: datasource.extra,
external_url: datasource.external_url, is_managed_externally: datasource.is_managed_externally,
metrics: datasource?.metrics?.map((metric: DatasetObject['metrics'][0]) => { external_url: datasource.external_url,
const metricBody: any = { metrics: datasource?.metrics?.map(
expression: metric.expression, (metric: DatasetObject['metrics'][0]) => {
description: metric.description, const metricBody: any = {
metric_name: metric.metric_name, expression: metric.expression,
metric_type: metric.metric_type, description: metric.description,
d3format: metric.d3format || null, metric_name: metric.metric_name,
currency: !isDefined(metric.currency) metric_type: metric.metric_type,
? null d3format: metric.d3format || null,
: JSON.stringify(metric.currency), currency: !isDefined(metric.currency)
verbose_name: metric.verbose_name, ? null
warning_text: metric.warning_text, : JSON.stringify(metric.currency),
uuid: metric.uuid, verbose_name: metric.verbose_name,
extra: buildExtraJsonObject(metric), warning_text: metric.warning_text,
}; uuid: metric.uuid,
if (!Number.isNaN(Number(metric.id))) { extra: buildExtraJsonObject(metric),
metricBody.id = metric.id; };
} if (!Number.isNaN(Number(metric.id))) {
return metricBody; metricBody.id = metric.id;
}), }
columns: datasource?.columns?.map( return metricBody;
(column: DatasetObject['columns'][0]) => ({ },
id: typeof column.id === 'number' ? column.id : undefined, ),
column_name: column.column_name, columns: datasource?.columns?.map(
type: column.type, (column: DatasetObject['columns'][0]) => ({
advanced_data_type: column.advanced_data_type, id: typeof column.id === 'number' ? column.id : undefined,
verbose_name: column.verbose_name, column_name: column.column_name,
description: column.description, type: column.type,
expression: column.expression, advanced_data_type: column.advanced_data_type,
filterable: column.filterable, verbose_name: column.verbose_name,
groupby: column.groupby, description: column.description,
is_active: column.is_active, expression: column.expression,
is_dttm: column.is_dttm, filterable: column.filterable,
python_date_format: column.python_date_format || null, groupby: column.groupby,
uuid: column.uuid, is_active: column.is_active,
extra: buildExtraJsonObject(column), is_dttm: column.is_dttm,
}), python_date_format: column.python_date_format || null,
), uuid: column.uuid,
owners: datasource.owners.map( extra: buildExtraJsonObject(column),
(o: Record<string, number>) => o.value || o.id, }),
), ),
}); owners: datasource.owners.map(
(o: Record<string, number>) => o.value || o.id,
),
};
// Handle catalog based on database's allow_multi_catalog setting
// If multi-catalog is disabled, don't include catalog in payload
// The backend will use the default catalog
// If multi-catalog is enabled, include the selected catalog
if (datasource.database?.allow_multi_catalog) {
payload.catalog = datasource.catalog;
}
return payload;
};
const onConfirmSave = async () => { const onConfirmSave = async () => {
// Pull out extra fields into the extra object // Pull out extra fields into the extra object
setIsSaving(true); setIsSaving(true);

View File

@@ -29,13 +29,20 @@ const defaultProps = {
onChange: jest.fn(), onChange: jest.fn(),
compact: false, compact: false,
inline: false, inline: false,
additionalControl: (
<input type="button" data-test="mock-text-aditional-control" />
),
}; };
test('should render', () => { test('should render', () => {
const { container } = render(<Field {...defaultProps} />); const { container } = render(<Field {...defaultProps} />);
expect(container).toBeInTheDocument(); expect(container).toBeInTheDocument();
}); });
test('should render with aditional control', () => {
const { getByTestId } = render(<Field {...defaultProps} />);
const additionalControl = getByTestId('mock-text-aditional-control');
expect(additionalControl).toBeInTheDocument();
});
test('should call onChange', () => { test('should call onChange', () => {
const { getByTestId } = render(<Field {...defaultProps} />); const { getByTestId } = render(<Field {...defaultProps} />);
const textArea = getByTestId('mock-text-control'); const textArea = getByTestId('mock-text-control');
@@ -47,3 +54,9 @@ test('should render compact', () => {
render(<Field {...defaultProps} compact />); render(<Field {...defaultProps} compact />);
expect(screen.queryByText(defaultProps.description)).not.toBeInTheDocument(); expect(screen.queryByText(defaultProps.description)).not.toBeInTheDocument();
}); });
test('shiuld render error message', () => {
const { getByText } = render(
<Field {...defaultProps} errorMessage="error message" />,
);
expect(getByText('error message')).toBeInTheDocument();
});

View File

@@ -21,6 +21,7 @@ import { useCallback, ReactNode, ReactElement, cloneElement } from 'react';
import { css, SupersetTheme } from '@superset-ui/core'; import { css, SupersetTheme } from '@superset-ui/core';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
import { FormItem, FormLabel } from 'src/components/Form'; import { FormItem, FormLabel } from 'src/components/Form';
import { Icons } from 'src/components/Icons';
const formItemInlineCss = css` const formItemInlineCss = css`
.ant-form-item-control-input-content { .ant-form-item-control-input-content {
@@ -28,16 +29,17 @@ const formItemInlineCss = css`
flex-direction: row; flex-direction: row;
} }
`; `;
interface FieldProps<V> { interface FieldProps<V> {
fieldKey: string; fieldKey: string;
value?: V; value?: V;
label: string; label: string;
description?: ReactNode; description?: ReactNode;
control: ReactElement; control: ReactElement;
additionalControl?: ReactElement;
onChange: (fieldKey: string, newValue: V) => void; onChange: (fieldKey: string, newValue: V) => void;
compact: boolean; compact: boolean;
inline: boolean; inline: boolean;
errorMessage?: string;
} }
export default function Field<V>({ export default function Field<V>({
@@ -46,9 +48,11 @@ export default function Field<V>({
label, label,
description = null, description = null,
control, control,
additionalControl,
onChange = () => {}, onChange = () => {},
compact = false, compact = false,
inline, inline,
errorMessage,
}: FieldProps<V>) { }: FieldProps<V>) {
const onControlChange = useCallback( const onControlChange = useCallback(
newValue => { newValue => {
@@ -62,32 +66,51 @@ export default function Field<V>({
onChange: onControlChange, onChange: onControlChange,
}); });
return ( return (
<FormItem <div
label={ css={
<FormLabel className="m-r-5"> additionalControl &&
{label || fieldKey} css`
{compact && description && ( position: relative;
<Tooltip id="field-descr" placement="right" title={description}> `
{/* TODO: Remove fa-icon */}
{/* eslint-disable-next-line icons/no-fa-icons-usage */}
<i className="fa fa-info-circle m-l-5" />
</Tooltip>
)}
</FormLabel>
} }
css={inline && formItemInlineCss}
> >
{hookedControl} {additionalControl}
{!compact && description && ( <FormItem
label={
<FormLabel className="m-r-5">
{label || fieldKey}
{compact && description && (
<Tooltip id="field-descr" placement="right" title={description}>
<Icons.InfoCircleFilled iconSize="s" className="m-l-5" />
</Tooltip>
)}
</FormLabel>
}
css={inline && formItemInlineCss}
>
{hookedControl}
{!compact && description && (
<div
css={(theme: SupersetTheme) => ({
color: theme.colors.grayscale.base,
[inline ? 'marginLeft' : 'marginTop']: theme.gridUnit,
})}
>
{description}
</div>
)}
</FormItem>
{errorMessage && (
<div <div
css={(theme: SupersetTheme) => ({ css={(theme: SupersetTheme) => ({
color: theme.colors.grayscale.base, color: theme.colors.error.base,
[inline ? 'marginLeft' : 'marginTop']: theme.gridUnit, marginTop: -16,
fontSize: theme.typography.sizes.s,
})} })}
> >
{description} {errorMessage}
</div> </div>
)} )}
</FormItem> </div>
); );
} }

View File

@@ -30,10 +30,12 @@ import {
BarChartOutlined, BarChartOutlined,
BellOutlined, BellOutlined,
BookOutlined, BookOutlined,
BulbOutlined,
CaretUpOutlined, CaretUpOutlined,
CaretDownOutlined, CaretDownOutlined,
CaretLeftOutlined, CaretLeftOutlined,
CaretRightOutlined, CaretRightOutlined,
CaretRightFilled,
CalendarOutlined, CalendarOutlined,
CheckOutlined, CheckOutlined,
CheckCircleOutlined, CheckCircleOutlined,
@@ -60,6 +62,7 @@ import {
EyeOutlined, EyeOutlined,
EyeInvisibleOutlined, EyeInvisibleOutlined,
FallOutlined, FallOutlined,
FieldNumberOutlined,
FieldTimeOutlined, FieldTimeOutlined,
FileImageOutlined, FileImageOutlined,
FileOutlined, FileOutlined,
@@ -89,6 +92,7 @@ import {
SaveOutlined, SaveOutlined,
SearchOutlined, SearchOutlined,
SettingOutlined, SettingOutlined,
ShareAltOutlined,
StarOutlined, StarOutlined,
StarFilled, StarFilled,
StopOutlined, StopOutlined,
@@ -130,10 +134,12 @@ const AntdIcons = {
BarChartOutlined, BarChartOutlined,
BellOutlined, BellOutlined,
BookOutlined, BookOutlined,
BulbOutlined,
CaretUpOutlined, CaretUpOutlined,
CaretDownOutlined, CaretDownOutlined,
CaretLeftOutlined, CaretLeftOutlined,
CaretRightOutlined, CaretRightOutlined,
CaretRightFilled,
CalendarOutlined, CalendarOutlined,
CheckOutlined, CheckOutlined,
CheckCircleOutlined, CheckCircleOutlined,
@@ -160,6 +166,7 @@ const AntdIcons = {
EyeOutlined, EyeOutlined,
EyeInvisibleOutlined, EyeInvisibleOutlined,
FallOutlined, FallOutlined,
FieldNumberOutlined,
FieldTimeOutlined, FieldTimeOutlined,
FileImageOutlined, FileImageOutlined,
FileOutlined, FileOutlined,
@@ -189,6 +196,7 @@ const AntdIcons = {
SaveOutlined, SaveOutlined,
SearchOutlined, SearchOutlined,
SettingOutlined, SettingOutlined,
ShareAltOutlined,
StarOutlined, StarOutlined,
StarFilled, StarFilled,
StopOutlined, StopOutlined,

View File

@@ -443,6 +443,7 @@ const Select = forwardRef(
<StyledBulkActionsContainer className="select-bulk-actions" size={0}> <StyledBulkActionsContainer className="select-bulk-actions" size={0}>
<Button <Button
type="link" type="link"
buttonStyle="link"
buttonSize="xsmall" buttonSize="xsmall"
disabled={bulkSelectCounts.selectable === 0} disabled={bulkSelectCounts.selectable === 0}
onMouseDown={e => { onMouseDown={e => {
@@ -455,6 +456,7 @@ const Select = forwardRef(
</Button> </Button>
<Button <Button
type="link" type="link"
buttonStyle="link"
buttonSize="xsmall" buttonSize="xsmall"
disabled={bulkSelectCounts.deselectable === 0} disabled={bulkSelectCounts.deselectable === 0}
onMouseDown={e => { onMouseDown={e => {

View File

@@ -51,6 +51,7 @@ import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
import { componentShape } from 'src/dashboard/util/propShapes'; import { componentShape } from 'src/dashboard/util/propShapes';
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions'; import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants'; import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
import { EMPTY_CONTAINER_Z_INDEX } from 'src/dashboard/constants'; import { EMPTY_CONTAINER_Z_INDEX } from 'src/dashboard/constants';
import { isCurrentUserBot } from 'src/utils/isBot'; import { isCurrentUserBot } from 'src/utils/isBot';
import { useDebouncedEffect } from '../../../explore/exploreUtils'; import { useDebouncedEffect } from '../../../explore/exploreUtils';
@@ -188,7 +189,10 @@ const Row = props => {
observerDisabler = new IntersectionObserver( observerDisabler = new IntersectionObserver(
([entry]) => { ([entry]) => {
if (!entry.isIntersecting && isComponentVisibleRef.current) { if (!entry.isIntersecting && isComponentVisibleRef.current) {
setIsInView(false); // Reference: https://www.w3.org/TR/intersection-observer/#dom-intersectionobserver-rootmargin
if (!isEmbedded()) {
setIsInView(false);
}
} }
}, },
{ {

View File

@@ -0,0 +1,26 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const isEmbedded = () => {
try {
return window.self !== window.top || window.frameElement !== null;
} catch (e) {
return true;
}
};

View File

@@ -0,0 +1,68 @@
/**
* 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 { makeApi } from '@superset-ui/core';
import { ThunkDispatch } from 'redux-thunk';
import { AnyAction } from 'redux';
import { QueryExecutePayload, QueryExecuteResponse } from './types';
export const executeQueryApi = makeApi<
QueryExecutePayload,
QueryExecuteResponse
>({
method: 'POST',
endpoint: '/api/v1/sqllab/execute',
});
export function setQueryIsLoading(isLoading: boolean) {
return {
type: 'SET_QUERY_IS_LOADING',
payload: isLoading,
};
}
export function setQueryResult(queryResult: QueryExecuteResponse) {
return {
type: 'SET_QUERY_RESULT',
payload: queryResult,
};
}
export function resetDatabaseState() {
return {
type: 'RESET_DATABASE_STATE',
};
}
export function setQueryError(error: string) {
return {
type: 'SET_QUERY_ERROR',
payload: error,
};
}
export function executeQuery(payload: QueryExecutePayload) {
return async function (dispatch: ThunkDispatch<any, undefined, AnyAction>) {
try {
dispatch(setQueryIsLoading(true));
const result = await executeQueryApi(payload);
dispatch(setQueryResult(result as QueryExecuteResponse));
} catch (error) {
dispatch(setQueryError(error.message));
} finally {
dispatch(setQueryIsLoading(false));
}
};
}

View File

@@ -0,0 +1,56 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { QueryAdhocState } from './types';
const initialState: QueryAdhocState = {
isLoading: null,
sql: null,
queryResult: null,
error: null,
};
export default function databaseReducer(
state: QueryAdhocState = initialState,
action: any,
): QueryAdhocState {
switch (action.type) {
case 'SET_QUERY_IS_LOADING':
return {
...state,
isLoading: action.payload,
};
case 'SET_QUERY_RESULT':
return {
...state,
sql: action.payload.query.sql ?? '',
queryResult: action.payload,
error: null,
};
case 'SET_QUERY_ERROR':
return {
...initialState,
error: action.payload,
};
case 'RESET_DATABASE_STATE':
return initialState;
default:
return state;
}
}

View File

@@ -0,0 +1,57 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export interface QueryExecutePayload {
client_id: string;
database_id: number;
json: boolean;
runAsync: boolean;
catalog: string | null;
schema: string;
sql: string;
tmp_table_name: string;
select_as_cta: boolean;
ctas_method: string;
queryLimit: number;
expand_data: boolean;
}
export interface Column {
name: string;
type: string;
is_dttm: boolean;
type_generic: number;
is_hidden: boolean;
column_name: string;
}
export interface QueryExecuteResponse {
status: string;
query_id: string;
data: any[];
columns: Column[];
selected_columns: Column[];
expanded_columns: Column[];
query: any;
}
export interface QueryAdhocState {
isLoading: boolean | null;
sql: string | null;
queryResult: QueryExecuteResponse | null;
error: string | null;
}

View File

@@ -23,7 +23,7 @@ import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route } from 'react-router-dom'; import { BrowserRouter as Router, Route } from 'react-router-dom';
import { makeApi, t, logging } from '@superset-ui/core'; import { makeApi, t, logging } from '@superset-ui/core';
import Switchboard from '@superset-ui/switchboard'; import Switchboard from '@superset-ui/switchboard';
import getBootstrapData from 'src/utils/getBootstrapData'; import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
import setupClient from 'src/setup/setupClient'; import setupClient from 'src/setup/setupClient';
import setupPlugins from 'src/setup/setupPlugins'; import setupPlugins from 'src/setup/setupPlugins';
import { useUiConfig } from 'src/components/UiConfigContext'; import { useUiConfig } from 'src/components/UiConfigContext';
@@ -94,7 +94,7 @@ const EmbeddedRoute = () => (
); );
const EmbeddedApp = () => ( const EmbeddedApp = () => (
<Router> <Router basename={applicationRoot()}>
{/* todo (embedded) remove this line after uuids are deployed */} {/* todo (embedded) remove this line after uuids are deployed */}
<Route path="/dashboard/:idOrSlug/embedded/" component={EmbeddedRoute} /> <Route path="/dashboard/:idOrSlug/embedded/" component={EmbeddedRoute} />
<Route path="/embedded/:uuid/" component={EmbeddedRoute} /> <Route path="/embedded/:uuid/" component={EmbeddedRoute} />
@@ -187,6 +187,7 @@ function start() {
*/ */
function setupGuestClient(guestToken: string) { function setupGuestClient(guestToken: string) {
setupClient({ setupClient({
appRoot: applicationRoot(),
guestToken, guestToken,
guestTokenHeaderName: bootstrapData.config?.GUEST_TOKEN_HEADER_NAME, guestTokenHeaderName: bootstrapData.config?.GUEST_TOKEN_HEADER_NAME,
unauthorizedHandler: guestUnauthorizedHandler, unauthorizedHandler: guestUnauthorizedHandler,

View File

@@ -57,6 +57,7 @@ export const useResultsPane = ({
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [responseError, setResponseError] = useState<string>(''); const [responseError, setResponseError] = useState<string>('');
const queryCount = metadata?.queryObjectCount ?? 1; const queryCount = metadata?.queryObjectCount ?? 1;
const isQueryCountDynamic = metadata?.dynamicQueryObjectCount;
useEffect(() => { useEffect(() => {
// it's an invalid formData when gets a errorMessage // it's an invalid formData when gets a errorMessage
@@ -139,19 +140,21 @@ export const useResultsPane = ({
<EmptyState image="document.svg" title={title} />, <EmptyState image="document.svg" title={title} />,
); );
} }
return resultResp const resultRespToDisplay = isQueryCountDynamic
.slice(0, queryCount) ? resultResp
.map((result, idx) => ( : resultResp.slice(0, queryCount);
<SingleQueryResultPane
data={result.data} return resultRespToDisplay.map((result, idx) => (
colnames={result.colnames} <SingleQueryResultPane
coltypes={result.coltypes} data={result.data}
rowcount={result.rowcount} colnames={result.colnames}
dataSize={dataSize} coltypes={result.coltypes}
datasourceId={queryFormData.datasource} rowcount={result.rowcount}
key={idx} dataSize={dataSize}
isVisible={isVisible} datasourceId={queryFormData.datasource}
canDownload={canDownload} key={idx}
/> isVisible={isVisible}
)); canDownload={canDownload}
/>
));
}; };

View File

@@ -174,4 +174,33 @@ describe('ResultsPaneOnDashboard', () => {
expect(await findByText('Results')).toBeVisible(); expect(await findByText('Results')).toBeVisible();
expect(await findByText('Results 2')).toBeVisible(); expect(await findByText('Results 2')).toBeVisible();
}); });
test('dynamic number of results pane', async () => {
const FakeChart = () => <span>test</span>;
const metadata = new ChartMetadata({
name: 'test-chart',
thumbnail: '',
dynamicQueryObjectCount: true,
});
const plugin = new ChartPlugin({
metadata,
Chart: FakeChart,
});
plugin.configure({ key: VizType.MixedTimeseries }).register();
const props = createResultsPaneOnDashboardProps({
sliceId: 196,
vizType: VizType.MixedTimeseries,
});
const { findByText, queryByText } = render(
<ResultsPaneOnDashboard {...props} />,
{
useRedux: true,
},
);
expect(await findByText('Results')).toBeVisible();
expect(await findByText('Results 2')).toBeVisible();
expect(queryByText('Results 3')).not.toBeInTheDocument();
});
}); });

View File

@@ -0,0 +1,67 @@
/**
* 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 NumberControl from '.';
const mockedProps = {
min: -5,
max: 10,
step: 1,
default: 0,
};
test('render', () => {
const { container } = render(<NumberControl {...mockedProps} />);
expect(container).toBeInTheDocument();
});
test('type number', async () => {
const props = {
...mockedProps,
onChange: jest.fn(),
};
render(<NumberControl {...props} />);
const input = screen.getByRole('spinbutton');
await userEvent.type(input, '9');
expect(props.onChange).toHaveBeenCalledTimes(1);
expect(props.onChange).toHaveBeenLastCalledWith(9);
});
test('type >max', async () => {
const props = {
...mockedProps,
onChange: jest.fn(),
};
render(<NumberControl {...props} />);
const input = screen.getByRole('spinbutton');
await userEvent.type(input, '20');
expect(props.onChange).toHaveBeenCalledTimes(1);
expect(props.onChange).toHaveBeenLastCalledWith(2);
});
test('type NaN', async () => {
const props = {
...mockedProps,
onChange: jest.fn(),
};
render(<NumberControl {...props} />);
const input = screen.getByRole('spinbutton');
await userEvent.type(input, 'not a number');
expect(props.onChange).toHaveBeenCalledTimes(0);
});

View File

@@ -0,0 +1,78 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { styled } from '@superset-ui/core';
import { InputNumber } from 'src/components/Input';
import ControlHeader, { ControlHeaderProps } from '../../ControlHeader';
type NumberValueType = number | undefined;
export interface NumberControlProps extends ControlHeaderProps {
onChange?: (value: NumberValueType) => void;
value?: NumberValueType;
label?: string;
description?: string;
min?: number;
max?: number;
step?: number;
placeholder?: string;
disabled?: boolean;
}
const FullWidthDiv = styled.div`
width: 100%;
`;
const FullWidthInputNumber = styled(InputNumber)`
width: 100%;
`;
function parseValue(value: string | number | null | undefined) {
if (value === null || value === undefined || value === '') {
return undefined;
}
const num = Number(value);
return Number.isNaN(num) ? undefined : num;
}
export default function NumberControl({
min,
max,
step,
placeholder,
value,
onChange,
disabled,
...rest
}: NumberControlProps) {
return (
<FullWidthDiv>
<ControlHeader {...rest} />
<FullWidthInputNumber
min={min}
max={max}
step={step}
placeholder={placeholder}
value={value}
onChange={value => onChange?.(parseValue(value))}
disabled={disabled}
aria-label={rest.label}
/>
</FullWidthDiv>
);
}

View File

@@ -19,10 +19,7 @@
import { Component } from 'react'; import { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { TextArea } from 'src/components/Input'; import { TextArea } from 'src/components/Input';
import { import { Tooltip, TooltipProps } from 'src/components/Tooltip';
Tooltip,
TooltipProps as TooltipOptions,
} from 'src/components/Tooltip';
import { t, withTheme } from '@superset-ui/core'; import { t, withTheme } from '@superset-ui/core';
import Button from 'src/components/Button'; import Button from 'src/components/Button';
@@ -59,7 +56,7 @@ const propTypes = {
'vertical', 'vertical',
]), ]),
textAreaStyles: PropTypes.object, textAreaStyles: PropTypes.object,
tooltipOptions: PropTypes.oneOf([null, TooltipOptions]), tooltipOptions: PropTypes.oneOf([null, TooltipProps]),
}; };
const defaultProps = { const defaultProps = {

View File

@@ -53,6 +53,7 @@ import { ComparisonRangeLabel } from './ComparisonRangeLabel';
import LayerConfigsControl from './LayerConfigsControl/LayerConfigsControl'; import LayerConfigsControl from './LayerConfigsControl/LayerConfigsControl';
import MapViewControl from './MapViewControl/MapViewControl'; import MapViewControl from './MapViewControl/MapViewControl';
import ZoomConfigControl from './ZoomConfigControl/ZoomConfigControl'; import ZoomConfigControl from './ZoomConfigControl/ZoomConfigControl';
import NumberControl from './NumberControl';
const controlMap = { const controlMap = {
AnnotationLayerControl, AnnotationLayerControl,
@@ -90,6 +91,7 @@ const controlMap = {
ComparisonRangeLabel, ComparisonRangeLabel,
TimeOffsetControl, TimeOffsetControl,
ZoomConfigControl, ZoomConfigControl,
NumberControl,
...sharedControlComponents, ...sharedControlComponents,
}; };
export default controlMap; export default controlMap;

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