Compare commits

...

59 Commits

Author SHA1 Message Date
Beto Dealmeida
72f4a52c97 WIP 2025-05-30 13:42:31 -04:00
Beto Dealmeida
c85229d9b4 WIP 2025-05-30 13:15:10 -04:00
Vitor Avila
fdea4e21b0 feat: current_user_rls_rules Jinja macro (#33614) 2025-05-29 11:58:40 -03:00
Fardin Mustaque
e20a08cb14 fix: add query identifier to legend items in mixed time series charts (#33519) 2025-05-29 14:42:48 +02:00
Levis Mbote
429935a277 fix(big number with trendline): add None option to the aggregation method dropdown (#33407) 2025-05-29 14:33:04 +02:00
Daniel Vaz Gaspar
a4bb11c755 chore: bump FAB to 4.7.0 (#33607) 2025-05-29 08:34:00 +01:00
gpchandran
f0b6e87091 chore: update Dockerfile - Upgrade to 3.11.12 (#33612) 2025-05-29 00:31:07 -07:00
Beto Dealmeida
ea5a609d0b feat: implement CVAS/CTAS in sqlglot (#33525) 2025-05-28 09:45:59 -04:00
Beto Dealmeida
0abe6eed89 feat: implement RLS in sqlglot (#33524) 2025-05-28 09:10:45 -04:00
Beto Dealmeida
e205846845 feat: implement CTEs logic in sqlglot (#33518) 2025-05-28 08:38:00 -04:00
Enzo Martellucci
deef923825 feat(Action Logs): Migrate Action Log FAB to React (#33298) 2025-05-28 14:08:00 +02:00
Beto Dealmeida
0fa3feb088 chore: remove parse_sql (#33474) 2025-05-27 18:03:55 -04:00
Beto Dealmeida
1393f7d3d2 chore: sql/parse cleanup (#33515) 2025-05-27 16:42:04 -04:00
Michael S. Molina
b7ba50033a fix: Makes time compare migration more resilient (#33592) 2025-05-27 17:02:10 -03:00
Michael S. Molina
ce9759785a fix: Missing processor context when rendering Jinja (#33596) 2025-05-27 16:54:54 -03:00
Beto Dealmeida
8de58b9848 feat: use sqlglot to set limit (#33473) 2025-05-27 15:20:02 -04:00
Sam Firke
cc8ab2c556 chore(alerts & reports): increase Playwright timeout from 30 -> 60 seconds (#33567) 2025-05-27 13:22:23 -04:00
Urban Pettersson
1409b1a25b fix: correct typos (#33586)
Co-authored-by: Urban Pettersson <urban.pettersson@alteryx.com>
2025-05-27 08:24:17 -07:00
amaannawab923
bdfb698aa4 fix(Radar): Radar chart normalisation (#33559)
Co-authored-by: Amaan Nawab <nelsondrew07@gmail.com>
2025-05-26 22:01:17 +03:00
Luiz Otavio
57183da315 fix: Adjust viz migrations to also migrate the queries object (#33285)
Co-authored-by: Michael S. Molina <michael.s.molina@gmail.com>
Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>
2025-05-26 15:00:07 -03:00
Sam Firke
c928f23e1b docs(docker build): add more packages needed for production features (#33566) 2025-05-24 19:30:33 -04:00
dependabot[bot]
0c89914a6d chore(deps-dev): bump eslint-config-prettier from 9.1.0 to 10.1.5 in /superset-websocket (#33478)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-24 12:52:56 +07:00
dependabot[bot]
630e0e0240 chore(deps-dev): bump babel-loader from 9.2.1 to 10.0.0 in /superset-frontend (#33489)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-23 14:57:36 -06:00
dependabot[bot]
513047c3bb chore(deps): bump less-loader from 11.1.4 to 12.3.0 in /docs (#33488)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-23 14:54:30 -06:00
dependabot[bot]
d932837a3c chore(deps-dev): bump eslint from 9.17.0 to 9.27.0 in /superset-websocket (#33477)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-23 14:54:15 -06:00
Richard Fogaca Nienkotter
38868f9ff4 fix(sankey): incorrect nodeValues (#33431)
Co-authored-by: richardfn <richard.fogaca@appsilon.com>
2025-05-23 14:52:58 -06:00
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
155 changed files with 7837 additions and 2680 deletions

View File

@@ -27,6 +27,8 @@ updates:
- package-ecosystem: "uv"
directory: "requirements/"
open-pull-requests-limit: 10
schedule:
interval: "weekly"
labels:
- uv
- 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
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
steps:
- name: "Checkout Repository"

View File

@@ -20,7 +20,7 @@ jobs:
steps:
- uses: actions/checkout@v4
# Do not bump this linkinator-action version without opening
# an ASF Infra ticket to allow the new verison first!
# an ASF Infra ticket to allow the new version first!
- uses: JustinBeckwith/linkinator-action@v1.11.0
continue-on-error: true # This will make the job advisory (non-blocking, no red X)
with:

View File

@@ -18,7 +18,7 @@
######################################################################
# Node stage to deal with static asset construction
######################################################################
ARG PY_VER=3.11.11-slim-bookworm
ARG PY_VER=3.11.12-slim-bookworm
# If BUILDPLATFORM is null, set it to 'amd64' (or leave as is otherwise).
ARG BUILDPLATFORM=${BUILDPLATFORM:-amd64}

View File

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

View File

@@ -250,6 +250,14 @@ Will be rendered as:
SELECT * FROM users WHERE role IN ('admin', 'viewer')
```
**Current User RLS Rules**
The `{{ current_user_rls_rules() }}` macro returns an array of RLS rules applied to the current dataset for the logged in user.
If you have caching enabled in your Superset configuration, then the list of RLS Rules will be used
by Superset when calculating the cache key. A cache key is a unique identifier that determines if there's a
cache hit in the future and Superset can retrieve cached data.
**Custom URL Parameters**
The `{{ url_param('custom_variable') }}` macro lets you define arbitrary URL

View File

@@ -64,6 +64,56 @@ 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)
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 \
# openpyxl to be able to upload Excel files
openpyxl \
# Pillow for Alerts & Reports to generate PDFs of dashboards
Pillow \
# 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 install-deps \
&& 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
- `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.
Ideally you will build your own image of Superset that extends `lean`, adding what your deployment needs.
See [Docker Build Presets](/docs/installation/docker-builds/#build-presets) for more information about the different image versions you can extend.
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).
## [Kubernetes (K8s)](/docs/installation/kubernetes.mdx)

View File

@@ -2,6 +2,12 @@
title: CVEs fixed by release
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
| CVE | Title | Affected |

View File

@@ -26,10 +26,10 @@
"@emotion/styled": "^10.0.27",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@superset-ui/style": "^0.14.23",
"antd": "^5.24.9",
"antd": "^5.25.1",
"docusaurus-plugin-less": "^2.0.2",
"less": "^4.3.0",
"less-loader": "^11.0.0",
"less-loader": "^12.3.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -44,12 +44,12 @@
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.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-react": "^7.37.5",
"prettier": "^2.0.0",
"typescript": "~5.8.3",
"webpack": "^5.99.7"
"webpack": "^5.99.8"
},
"browserslist": {
"production": [

View File

@@ -4179,10 +4179,10 @@ ansi-styles@^6.1.0:
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
antd@^5.24.9:
version "5.24.9"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.24.9.tgz#c5862e02ed770bd95e312961f4f0b7b158a004d9"
integrity sha512-liB+Y/JwD5/KSKbK1Z1EVAbWcoWYvWJ1s97AbbT+mOdigpJQuWwH7kG8IXNEljI7onvj0DdD43TXhSRLUu9AMA==
antd@^5.25.1:
version "5.25.1"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.25.1.tgz#859b419a18d113492304ccd66c29074a71902241"
integrity sha512-4KC7KuPCjr0z3Vuw9DsF+ceqJaPLbuUI3lOX1sY8ix25ceamp+P8yxOmk3Y2JHCD2ZAhq+5IQ/DTJRN2adWYKQ==
dependencies:
"@ant-design/colors" "^7.2.0"
"@ant-design/cssinjs" "^1.23.0"
@@ -4199,7 +4199,7 @@ antd@^5.24.9:
classnames "^2.5.1"
copy-to-clipboard "^3.3.3"
dayjs "^1.11.11"
rc-cascader "~3.33.1"
rc-cascader "~3.34.0"
rc-checkbox "~3.5.0"
rc-collapse "~3.9.0"
rc-dialog "~9.6.0"
@@ -4219,7 +4219,7 @@ antd@^5.24.9:
rc-rate "~2.13.1"
rc-resize-observer "^1.4.3"
rc-segmented "~2.7.0"
rc-select "~14.16.6"
rc-select "~14.16.7"
rc-slider "~11.1.8"
rc-steps "~6.0.1"
rc-switch "~4.1.0"
@@ -4229,7 +4229,7 @@ antd@^5.24.9:
rc-tooltip "~6.4.0"
rc-tree "~5.13.1"
rc-tree-select "~5.27.0"
rc-upload "~4.8.1"
rc-upload "~4.9.0"
rc-util "^5.44.4"
scroll-into-view-if-needed "^3.1.0"
throttle-debounce "^5.0.2"
@@ -6247,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"
integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==
eslint-config-prettier@^10.1.2:
version "10.1.2"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.2.tgz#31a4b393c40c4180202c27e829af43323bf85276"
integrity sha512-Epgp/EofAUeEpIdZkW60MHKvPyru1ruQJxPL+WIycnaPApuseK0Zpkrh/FwL9oIpQvIhJwV7ptOy0DWUjTlCiA==
eslint-config-prettier@^10.1.5:
version "10.1.5"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz#00c18d7225043b6fbce6a665697377998d453782"
integrity sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==
eslint-plugin-prettier@^4.0.0:
version "4.2.1"
@@ -8211,10 +8211,10 @@ layout-base@^2.0.0:
resolved "https://registry.yarnpkg.com/layout-base/-/layout-base-2.0.1.tgz#d0337913586c90f9c2c075292069f5c2da5dd285"
integrity sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==
less-loader@^11.0.0:
version "11.1.4"
resolved "https://registry.npmjs.org/less-loader/-/less-loader-11.1.4.tgz"
integrity sha512-6/GrYaB6QcW6Vj+/9ZPgKKs6G10YZai/l/eJ4SLwbzqNTBsAqt5hSLVF47TgsiBxV1P6eAU0GYRH3YRuQU9V3A==
less-loader@^12.3.0:
version "12.3.0"
resolved "https://registry.yarnpkg.com/less-loader/-/less-loader-12.3.0.tgz#d4a00361568be86a97da3df4f16954b0d4c15340"
integrity sha512-0M6+uYulvYIWs52y0LqN4+QM9TqWAohYSNTo4htE8Z7Cn3G/qQMEmktfHmyJT23k+20kU9zHH2wrfFXkxNLtVw==
less@^4.3.0:
version "4.3.0"
@@ -10605,10 +10605,10 @@ raw-body@2.5.2:
iconv-lite "0.4.24"
unpipe "1.0.0"
rc-cascader@~3.33.1:
version "3.33.1"
resolved "https://registry.yarnpkg.com/rc-cascader/-/rc-cascader-3.33.1.tgz#19e01462ef5ef51b723c1f562c7b9cde4691e7ee"
integrity sha512-Kyl4EJ7ZfCBuidmZVieegcbFw0RcU5bHHSbtEdmuLYd0fYHCAiYKZ6zon7fWAVyC6rWWOOib0XKdTSf7ElC9rg==
rc-cascader@~3.34.0:
version "3.34.0"
resolved "https://registry.yarnpkg.com/rc-cascader/-/rc-cascader-3.34.0.tgz#56f936ab6b1229bab7d558701ce9b9e96536582c"
integrity sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==
dependencies:
"@babel/runtime" "^7.25.7"
classnames "^2.3.1"
@@ -10821,10 +10821,10 @@ rc-segmented@~2.7.0:
rc-motion "^2.4.4"
rc-util "^5.17.0"
rc-select@~14.16.2, rc-select@~14.16.6:
version "14.16.6"
resolved "https://registry.npmjs.org/rc-select/-/rc-select-14.16.6.tgz"
integrity sha512-YPMtRPqfZWOm2XGTbx5/YVr1HT0vn//8QS77At0Gjb3Lv+Lbut0IORJPKLWu1hQ3u4GsA0SrDzs7nI8JG7Zmyg==
rc-select@~14.16.2, rc-select@~14.16.7:
version "14.16.8"
resolved "https://registry.yarnpkg.com/rc-select/-/rc-select-14.16.8.tgz#78e6782f1ccc1f03d9003bc3effa4ed609d29a97"
integrity sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==
dependencies:
"@babel/runtime" "^7.10.1"
"@rc-component/trigger" "^2.1.1"
@@ -10929,10 +10929,10 @@ rc-tree@~5.13.0, rc-tree@~5.13.1:
rc-util "^5.16.1"
rc-virtual-list "^3.5.1"
rc-upload@~4.8.1:
version "4.8.1"
resolved "https://registry.npmjs.org/rc-upload/-/rc-upload-4.8.1.tgz"
integrity sha512-toEAhwl4hjLAI1u8/CgKWt30BR06ulPa4iGQSMvSXoHzO88gPCslxqV/mnn4gJU7PDoltGIC9Eh+wkeudqgHyw==
rc-upload@~4.9.0:
version "4.9.0"
resolved "https://registry.yarnpkg.com/rc-upload/-/rc-upload-4.9.0.tgz#911963ab5a0b538c743765371c05e2de9e3f5436"
integrity sha512-pAzlPnyiFn1GCtEybEG2m9nXNzQyWXqWV2xFYCmDxjN9HzyjS5Pz2F+pbNdYw8mMJsixLEKLG0wVy9vOGxJMJA==
dependencies:
"@babel/runtime" "^7.18.3"
classnames "^2.2.5"
@@ -13033,10 +13033,10 @@ webpack-sources@^3.2.3:
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
webpack@^5.88.1, webpack@^5.95.0, webpack@^5.99.7:
version "5.99.7"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.99.7.tgz#60201c1ca66da046b07d006c2f6e0cc5e8a7bdba"
integrity sha512-CNqKBRMQjwcmKR0idID5va1qlhrqVUKpovi+Ec79ksW8ux7iS1+A6VqzfZXgVYCFRKl7XL5ap3ZoMpwBJxcg0w==
webpack@^5.88.1, webpack@^5.95.0, webpack@^5.99.8:
version "5.99.8"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.99.8.tgz#dd31a020b7c092d30c4c6d9a4edb95809e7f5946"
integrity sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==
dependencies:
"@types/eslint-scope" "^3.7.7"
"@types/estree" "^1.0.6"

View File

@@ -44,7 +44,7 @@ dependencies = [
"cryptography>=42.0.4, <45.0.0",
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <3.0.0",
"flask-appbuilder>=4.6.3, <5.0.0",
"flask-appbuilder>=4.7.0, <5.0.0",
"flask-caching>=2.1.0, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
@@ -240,6 +240,12 @@ disallow_untyped_calls = false
disallow_untyped_defs = false
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]
# Exclude a variety of commonly ignored directories.
exclude = [
@@ -272,7 +278,6 @@ exclude = [
"venv",
]
# Same as Black.
line-length = 88
indent-width = 4
@@ -367,6 +372,7 @@ docstring-code-line-length = "dynamic"
requirement_txt_file = "requirements/base.txt"
authorized_licenses = [
"academic free license (afl)",
"any-osi",
"apache license 2.0",
"apache software",
"apache software, bsd",
@@ -380,6 +386,7 @@ authorized_licenses = [
"osi approved",
"psf-2.0",
"python software foundation",
"simplified bsd",
"the unlicense (unlicense)",
"the unlicense",
]

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -122,7 +122,7 @@
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"abortcontroller-polyfill": "^1.7.8",
"ace-builds": "^1.36.3",
"ace-builds": "^1.41.0",
"ag-grid-community": "33.1.1",
"ag-grid-react": "33.1.1",
"antd": "4.10.3",
@@ -137,6 +137,7 @@
"dayjs": "^1.11.13",
"dom-to-image-more": "^3.2.0",
"dom-to-pdf": "^0.3.2",
"dompurify": "^3.2.4",
"echarts": "^5.6.0",
"emotion-rgba": "0.0.12",
"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-transform-modules-commonjs": "^7.26.3",
"@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-typescript": "^7.26.0",
"@babel/register": "^7.23.7",
@@ -292,7 +293,7 @@
"@typescript-eslint/parser": "^5.62.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.8.0",
"babel-jest": "^29.7.0",
"babel-loader": "^9.1.3",
"babel-loader": "^10.0.0",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",

View File

@@ -30,7 +30,7 @@ export const aggregationOperator: PostProcessingFactory<
> = (formData: QueryFormData, queryObject) => {
const { aggregation = 'LAST_VALUE' } = formData;
if (aggregation === 'LAST_VALUE') {
if (aggregation === 'LAST_VALUE' || aggregation === 'raw') {
return undefined;
}

View File

@@ -70,6 +70,7 @@ export const aggregationControl = {
clearable: false,
renderTrigger: false,
choices: [
['raw', t('None')],
['LAST_VALUE', t('Last Value')],
['sum', t('Total (Sum)')],
['mean', t('Average (Mean)')],
@@ -77,7 +78,9 @@ export const aggregationControl = {
['max', t('Maximum')],
['median', t('Median')],
],
description: t('Select an aggregation method to apply to the metric.'),
description: t(
'Aggregation method used to compute the Big Number from the Trendline.For non-additive metrics like ratios, averages, distinct counts, etc use NONE.',
),
provideFormDataToProps: true,
mapStateToProps: ({ form_data }: ControlPanelState) => ({
value: form_data.aggregation || 'LAST_VALUE',

View File

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

View File

@@ -58,17 +58,18 @@ export default async function parseResponse<T extends ParseMethod = 'json'>(
const result: JsonResponse = {
response,
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 (
value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER)
value?.isInteger?.() === true &&
(value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER))
) {
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;
}),
};

View File

@@ -21,10 +21,18 @@ import { t } from '../translation';
export default function validateServerPagination(
v: unknown,
serverPagination: boolean,
max: number,
maxValueWithoutServerPagination: number,
maxServer: number,
) {
if (Number(v) > +max && !serverPagination) {
return t('Server pagination needs to be enabled for values over %s', max);
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 mockGetBigIntPayload = `{
"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",
"constructor": "constructor"
}`;
@@ -161,6 +161,7 @@ describe('parseResponse()', () => {
expect(responseBigNumber.json.floatValue.minus).toEqual(
-0.3452211361231223,
);
expect(responseBigNumber.json.floatValue.even).toEqual(1234567890123456);
expect(
responseBigNumber.json.floatValue.plus +
responseBigNumber.json.floatValue.minus,

View File

@@ -20,27 +20,134 @@
import { validateServerPagination } from '@superset-ui/core';
import './setup';
test('validateServerPagination returns warning message when server pagination is disabled and value exceeds max', () => {
expect(validateServerPagination(100001, false, 100000)).toBeTruthy();
expect(validateServerPagination('150000', false, 100000)).toBeTruthy();
expect(validateServerPagination(200000, false, 100000)).toBeTruthy();
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', () => {
expect(validateServerPagination(100001, true, 100000)).toBeFalsy();
expect(validateServerPagination(150000, true, 100000)).toBeFalsy();
expect(validateServerPagination('200000', true, 100000)).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 returns false when value is below max', () => {
expect(validateServerPagination(50000, false, 100000)).toBeFalsy();
expect(validateServerPagination('75000', false, 100000)).toBeFalsy();
expect(validateServerPagination(99999, false, 100000)).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, 100000)).toBeFalsy();
expect(validateServerPagination(null, false, 100000)).toBeFalsy();
expect(validateServerPagination(NaN, false, 100000)).toBeFalsy();
expect(validateServerPagination('invalid', false, 100000)).toBeFalsy();
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,11 +54,11 @@
},
"devDependencies": {
"@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-typescript": "^7.23.3",
"@storybook/react-webpack5": "8.2.9",
"babel-loader": "^9.1.3",
"babel-loader": "^10.0.0",
"fork-ts-checker-webpack-plugin": "^9.0.2",
"ts-loader": "^9.5.2",
"typescript": "^5.7.2"

View File

@@ -38,9 +38,20 @@ import {
} from '../DeckGLContainer';
import { getExploreLongUrl } from '../utils/explore';
import layerGenerators from '../layers';
import { Viewport } from '../utils/fitViewport';
import fitViewport, { Viewport } from '../utils/fitViewport';
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 = {
formData: QueryFormData;
payload: JsonObject;
@@ -56,7 +67,35 @@ export type DeckMultiProps = {
const DeckMulti = (props: DeckMultiProps) => {
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>>(
{},
);
@@ -70,23 +109,31 @@ const DeckMulti = (props: DeckMultiProps) => {
const loadLayers = useCallback(
(formData: QueryFormData, payload: JsonObject, viewport?: Viewport) => {
setViewport(viewport);
setViewport(getAdjustedViewport());
setSubSlicesLayers({});
payload.data.slices.forEach(
(subslice: { slice_id: number } & JsonObject) => {
// Filters applied to multi_deck are passed down to underlying charts
// note that dashboard contextual information (filter_immune_slices and such) aren't
// taken into consideration here
const filters = [
...(subslice.form_data.filters || []),
...(formData.filters || []),
const extra_filters = [
...(subslice.form_data.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 = {
...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);
@@ -136,7 +189,7 @@ const DeckMulti = (props: DeckMultiProps) => {
<DeckGLContainerStyledWrapper
ref={containerRef}
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport || props.viewport}
viewport={viewport}
layers={layers}
mapStyle={formData.mapbox_style}
setControlValue={setControlValue}

View File

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

View File

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

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);
}

View File

@@ -84,7 +84,7 @@ export function getLayer(
});
}
function getPoints(data: JsonObject[]) {
export function getPoints(data: JsonObject[]) {
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[] = [];
data.forEach(d => {
points = points.concat(d.path);

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,86 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { QueryFormData } from '@superset-ui/core';
import buildQuery from './buildQuery';
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
getXAxisColumn: jest.fn(() => 'order_date'),
isXAxisSet: jest.fn(() => true),
}));
jest.mock('@superset-ui/chart-controls', () => ({
pivotOperator: jest.fn(() => ({ operation: 'pivot' })),
aggregationOperator: jest.fn(formData => {
if (formData.aggregation === 'LAST_VALUE' || !formData.aggregation) {
return undefined;
}
return {
operation: 'aggregation',
options: { operator: formData.aggregation },
};
}),
flattenOperator: jest.fn(() => ({ operation: 'flatten' })),
resampleOperator: jest.fn(() => ({ operation: 'resample' })),
rollingWindowOperator: jest.fn(() => ({ operation: 'rolling' })),
}));
describe('BigNumberWithTrendline buildQuery', () => {
const baseFormData: QueryFormData = {
datasource: '1__table',
viz_type: 'big_number',
metric: 'custom_metric',
aggregation: null,
};
it('creates raw metric query when aggregation is null', () => {
const queryContext = buildQuery({ ...baseFormData });
const bigNumberQuery = queryContext.queries[1];
expect(bigNumberQuery.post_processing).toEqual([{ operation: 'pivot' }]);
expect(bigNumberQuery.is_timeseries).toBe(true);
});
it('adds aggregation operator when aggregation is "sum"', () => {
const queryContext = buildQuery({ ...baseFormData, aggregation: 'sum' });
const bigNumberQuery = queryContext.queries[1];
expect(bigNumberQuery.post_processing).toEqual([
{ operation: 'pivot' },
{ operation: 'aggregation', options: { operator: 'sum' } },
]);
expect(bigNumberQuery.is_timeseries).toBe(true);
});
it('skips aggregation when aggregation is LAST_VALUE', () => {
const queryContext = buildQuery({
...baseFormData,
aggregation: 'LAST_VALUE',
});
const bigNumberQuery = queryContext.queries[1];
expect(bigNumberQuery.post_processing).toEqual([{ operation: 'pivot' }]);
expect(bigNumberQuery.is_timeseries).toBe(true);
});
it('always returns two queries', () => {
const queryContext = buildQuery({ ...baseFormData });
expect(queryContext.queries.length).toBe(2);
});
});

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
buildQueryContext,
ensureIsArray,
@@ -32,15 +33,17 @@ import {
} from '@superset-ui/chart-controls';
export default function buildQuery(formData: QueryFormData) {
const isRawMetric = formData.aggregation === 'raw';
const timeColumn = isXAxisSet(formData)
? ensureIsArray(getXAxisColumn(formData))
: [];
return buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
columns: [
...(isXAxisSet(formData)
? ensureIsArray(getXAxisColumn(formData))
: []),
],
...(isXAxisSet(formData) ? {} : { is_timeseries: true }),
columns: [...timeColumn],
...(timeColumn.length ? {} : { is_timeseries: true }),
post_processing: [
pivotOperator(formData, baseQueryObject),
rollingWindowOperator(formData, baseQueryObject),
@@ -48,19 +51,16 @@ export default function buildQuery(formData: QueryFormData) {
flattenOperator(formData, baseQueryObject),
],
},
{
...baseQueryObject,
columns: [
...(isXAxisSet(formData)
? ensureIsArray(getXAxisColumn(formData))
: []),
],
...(isXAxisSet(formData) ? {} : { is_timeseries: true }),
post_processing: [
pivotOperator(formData, baseQueryObject),
aggregationOperator(formData, baseQueryObject),
],
columns: [...(isRawMetric ? [] : timeColumn)],
is_timeseries: !isRawMetric,
post_processing: isRawMetric
? []
: [
pivotOperator(formData, baseQueryObject),
aggregationOperator(formData, baseQueryObject),
],
},
]);
}

View File

@@ -97,6 +97,7 @@ import {
getXAxisFormatter,
getYAxisFormatter,
} from '../utils/formatters';
import { getMetricDisplayName } from '../utils/metricDisplayName';
const getFormatter = (
customFormatters: Record<string, ValueFormatter>,
@@ -222,6 +223,10 @@ export default function transformProps(
}
const rebasedDataA = rebaseForecastDatum(data1, verboseMap);
const MetricDisplayNameA = getMetricDisplayName(metrics[0], verboseMap);
const MetricDisplayNameB = getMetricDisplayName(metricsB[0], verboseMap);
const [rawSeriesA] = extractSeries(rebasedDataA, {
fillNeighborValue: stack ? 0 : undefined,
xAxis: xAxisLabel,
@@ -373,6 +378,12 @@ export default function transformProps(
const seriesName = inverted[entryName] || entryName;
const colorScaleKey = getOriginalSeries(seriesName, array);
let displayName = `${entryName} (Query A)`;
if (groupby.length > 0) {
displayName = `${MetricDisplayNameA} (Query A), ${entryName}`;
}
const seriesFormatter = getFormatter(
customFormatters,
formatter,
@@ -382,7 +393,10 @@ export default function transformProps(
);
const transformedSeries = transformSeries(
entry,
{
...entry,
id: `${displayName || ''}`,
},
colorScale,
colorScaleKey,
{
@@ -421,6 +435,12 @@ export default function transformProps(
const seriesName = `${seriesEntry} (1)`;
const colorScaleKey = getOriginalSeries(seriesEntry, array);
let displayName = `${entryName} (Query B)`;
if (groupbyB.length > 0) {
displayName = `${MetricDisplayNameB} (Query B), ${entryName}`;
}
const seriesFormatter = getFormatter(
customFormattersSecondary,
formatterSecondary,
@@ -430,7 +450,11 @@ export default function transformProps(
);
const transformedSeries = transformSeries(
entry,
{
...entry,
id: `${displayName || ''}`,
},
colorScale,
colorScaleKey,
{
@@ -444,9 +468,7 @@ export default function transformProps(
stackIdSuffix: '\nb',
yAxisIndex: yAxisIndexB,
filterState,
seriesKey: primarySeries.has(entry.name as string)
? `${entry.name} (1)`
: entry.name,
seriesKey: entry.name,
sliceId,
queryIndex: 1,
formatter:

View File

@@ -16,14 +16,30 @@
* specific language governing permissions and limitations
* 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) {
const { metric, sort_by_metric } = formData;
const metricLabel = getMetricLabel(metric);
return buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
...(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',

View File

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

View File

@@ -47,6 +47,7 @@ export type EchartsPieFormData = QueryFormData &
dateFormat: string;
showLabelsThreshold: number;
roseType: 'radius' | 'area' | null;
thresholdForOther: number;
};
export enum EchartsPieLabelType {
@@ -82,9 +83,20 @@ export const DEFAULT_FORM_DATA: EchartsPieFormData = {
showLabelsThreshold: 5,
dateFormat: 'smart_date',
roseType: null,
thresholdForOther: 0,
};
export type PieChartTransformedProps =
BaseTransformedProps<EchartsPieFormData> &
ContextMenuTransformedProps &
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

@@ -24,6 +24,7 @@ import {
getNumberFormatter,
getTimeFormatter,
NumberFormatter,
isDefined,
} from '@superset-ui/core';
import type { CallbackDataParams } from 'echarts/types/src/util/types';
import type { RadarSeriesDataItemOption } from 'echarts/types/src/chart/radar/RadarSeries';
@@ -35,6 +36,7 @@ import {
EchartsRadarFormData,
EchartsRadarLabelType,
RadarChartTransformedProps,
SeriesNormalizedMap,
} from './types';
import { DEFAULT_LEGEND_FORM_DATA, OpacityEnum } from '../constants';
import {
@@ -46,18 +48,31 @@ import {
import { defaultGrid } from '../defaults';
import { Refs } from '../types';
import { getDefaultTooltip } from '../utils/tooltip';
import { findGlobalMax, renderNormalizedTooltip } from './utils';
export function formatLabel({
params,
labelType,
numberFormatter,
getDenormalizedSeriesValue,
metricsWithCustomBounds,
metricLabels,
}: {
params: CallbackDataParams;
labelType: EchartsRadarLabelType;
numberFormatter: NumberFormatter;
getDenormalizedSeriesValue: (seriesName: string, value: string) => number;
metricsWithCustomBounds: Set<string>;
metricLabels: string[];
}): string {
const { name = '', value } = params;
const formattedValue = numberFormatter(value as number);
const { name = '', value, dimensionIndex = 0 } = params;
const metricLabel = metricLabels[dimensionIndex];
const formattedValue = numberFormatter(
metricsWithCustomBounds.has(metricLabel)
? (value as number)
: (getDenormalizedSeriesValue(name, String(value)) as number),
);
switch (labelType) {
case EchartsRadarLabelType.Value:
@@ -85,6 +100,7 @@ export default function transformProps(
} = chartProps;
const refs: Refs = {};
const { data = [] } = queriesData[0];
const globalMax = findGlobalMax(data, Object.keys(data[0] || {}));
const coltypeMapping = getColtypesMapping(queriesData[0]);
const {
@@ -111,14 +127,38 @@ export default function transformProps(
const { setDataMask = () => {}, onContextMenu } = hooks;
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
const numberFormatter = getNumberFormatter(numberFormat);
const denormalizedSeriesValues: SeriesNormalizedMap = {};
const getDenormalizedSeriesValue = (
seriesName: string,
normalizedValue: string,
): number =>
denormalizedSeriesValues?.[seriesName]?.[normalizedValue] ??
Number(normalizedValue);
const metricLabels = metrics.map(getMetricLabel);
const metricsWithCustomBounds = new Set(
metricLabels.filter(metricLabel => {
const config = columnConfig?.[metricLabel];
const hasMax = !!isDefined(config?.radarMetricMaxValue);
const hasMin =
isDefined(config?.radarMetricMinValue) &&
config?.radarMetricMinValue !== 0;
return hasMax || hasMin;
}),
);
const formatter = (params: CallbackDataParams) =>
formatLabel({
params,
numberFormatter,
labelType,
getDenormalizedSeriesValue,
metricsWithCustomBounds,
metricLabels,
});
const metricLabels = metrics.map(getMetricLabel);
const groupbyLabels = groupby.map(getColumnLabel);
const metricLabelAndMaxValueMap = new Map<string, number>();
@@ -212,28 +252,58 @@ export default function transformProps(
{},
);
const normalizeArray = (arr: number[], decimals = 10, seriesName: string) =>
arr.map((value, index) => {
const metricLabel = metricLabels[index];
if (metricsWithCustomBounds.has(metricLabel)) {
return value;
}
const max = Math.max(...arr);
const normalizedValue = Number((value / max).toFixed(decimals));
denormalizedSeriesValues[seriesName][String(normalizedValue)] = value;
return normalizedValue;
});
// Normalize the transformed data
const normalizedTransformedData = transformedData.map(series => {
if (Array.isArray(series.value)) {
const seriesName = String(series?.name || '');
denormalizedSeriesValues[seriesName] = {};
return {
...series,
value: normalizeArray(series.value as number[], 10, seriesName),
};
}
return series;
});
const indicator = metricLabels.map(metricLabel => {
const isMetricWithCustomBounds = metricsWithCustomBounds.has(metricLabel);
if (!isMetricWithCustomBounds) {
return {
name: metricLabel,
max: 1,
min: 0,
};
}
const maxValueInControl = columnConfig?.[metricLabel]?.radarMetricMaxValue;
const minValueInControl = columnConfig?.[metricLabel]?.radarMetricMinValue;
// Ensure that 0 is at the center of the polar coordinates
const metricValueAsMax =
const maxValue =
metricLabelAndMaxValueMap.get(metricLabel) === 0
? Number.MAX_SAFE_INTEGER
: metricLabelAndMaxValueMap.get(metricLabel);
const max =
maxValueInControl === null ? metricValueAsMax : maxValueInControl;
: globalMax;
const max = isDefined(maxValueInControl) ? maxValueInControl : maxValue;
let min: number;
// If the min value doesn't exist, set it to 0 (default),
// if it is null, set it to the min value of the data,
// otherwise, use the value from the control
if (minValueInControl === undefined) {
min = 0;
} else if (minValueInControl === null) {
min = metricLabelAndMinValueMap.get(metricLabel) || 0;
} else {
if (isDefined(minValueInControl)) {
min = minValueInControl;
} else {
min = 0;
}
return {
@@ -255,10 +325,24 @@ export default function transformProps(
backgroundColor: theme.colors.grayscale.light5,
},
},
data: transformedData,
data: normalizedTransformedData,
},
];
const NormalizedTooltipFormater = (
params: CallbackDataParams & {
color: string;
name: string;
value: number[];
},
) =>
renderNormalizedTooltip(
params,
metricLabels,
getDenormalizedSeriesValue,
metricsWithCustomBounds,
);
const echartOptions: EChartsCoreOption = {
grid: {
...defaultGrid,
@@ -267,6 +351,7 @@ export default function transformProps(
...getDefaultTooltip(refs),
show: !inContextMenu,
trigger: 'item',
formatter: NormalizedTooltipFormater,
},
legend: {
...getLegendProps(legendType, legendOrientation, showLegend, theme),

View File

@@ -35,7 +35,7 @@ import { DEFAULT_LEGEND_FORM_DATA } from '../constants';
type RadarColumnConfig = Record<
string,
{ radarMetricMaxValue?: number; radarMetricMinValue?: number }
{ radarMetricMaxValue?: number | null; radarMetricMinValue?: number }
>;
export type EchartsRadarFormData = QueryFormData &
@@ -53,6 +53,7 @@ export type EchartsRadarFormData = QueryFormData &
isCircle: boolean;
numberFormat: string;
dateFormat: string;
isNormalized: boolean;
};
export enum EchartsRadarLabelType {
@@ -83,3 +84,17 @@ export type RadarChartTransformedProps =
BaseTransformedProps<EchartsRadarFormData> &
ContextMenuTransformedProps &
CrossFilterTransformedProps;
/**
* Represents a mapping from a normalized value (as string) to an original numeric value.
*/
interface NormalizedValueMap {
[normalized: string]: number;
}
/**
* Represents a collection of series, each containing its own NormalizedValueMap.
*/
export interface SeriesNormalizedMap {
[seriesName: string]: NormalizedValueMap;
}

View File

@@ -0,0 +1,92 @@
/**
* 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.
*/
/*
function for finding the max metric values among all series data for Radar Chart
*/
export const findGlobalMax = (
data: Record<string, unknown>[],
metrics: string[],
): number => {
if (!data?.length || !metrics?.length) return 0;
return data.reduce((globalMax, row) => {
const rowMax = metrics.reduce((max, metric) => {
const value = row[metric];
return typeof value === 'number' &&
Number.isFinite(value) &&
!Number.isNaN(value)
? Math.max(max, value)
: max;
}, 0);
return Math.max(globalMax, rowMax);
}, 0);
};
interface TooltipParams {
color: string;
name?: string;
value: number[];
}
interface TooltipMetricValue {
metric: string;
value: number;
}
export const renderNormalizedTooltip = (
params: TooltipParams,
metrics: string[],
getDenormalizedValue: (seriesName: string, value: string) => number,
metricsWithCustomBounds: Set<string>,
): string => {
const { color, name = '', value: values } = params;
const seriesName = name || 'series0';
const colorDot = `<span style="display:inline-block;margin-right:5px;border-radius:50%;width:5px;height:5px;background-color:${color}"></span>`;
// Get metric values with denormalization if needed
const metricValues: TooltipMetricValue[] = metrics.map((metric, index) => {
const value = values[index];
const originalValue = metricsWithCustomBounds.has(metric)
? value
: getDenormalizedValue(name, String(value));
return {
metric,
value: originalValue,
};
});
const tooltipRows = metricValues
.map(
({ metric, value }) => `
<div style="display:flex;">
<div>${colorDot}${metric}:</div>
<div style="font-weight:bold;margin-left:auto;">${value}</div>
</div>
`,
)
.join('');
return `
<div style="font-weight:bold;margin-bottom:5px;">${seriesName}</div>
${tooltipRows}
`;
};

View File

@@ -73,13 +73,25 @@ export default function transformProps(
}));
// stores a map with the total values for each node considering the links
const nodeValues = new Map<string, number>();
const incomingFlows = new Map<string, number>();
const outgoingFlows = new Map<string, number>();
const allNodeNames = new Set<string>();
links.forEach(link => {
const { source, target, value } = link;
const sourceValue = nodeValues.get(source) || 0;
const targetValue = nodeValues.get(target) || 0;
nodeValues.set(source, sourceValue + value);
nodeValues.set(target, targetValue + value);
allNodeNames.add(source);
allNodeNames.add(target);
incomingFlows.set(target, (incomingFlows.get(target) || 0) + value);
outgoingFlows.set(source, (outgoingFlows.get(source) || 0) + value);
});
const nodeValues = new Map<string, number>();
allNodeNames.forEach(nodeName => {
const totalIncoming = incomingFlows.get(nodeName) || 0;
const totalOutgoing = outgoingFlows.get(nodeName) || 0;
nodeValues.set(nodeName, Math.max(totalIncoming, totalOutgoing));
});
const tooltipFormatter = (params: CallbackDataParams) => {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@superset-ui/core';
import { JsonArray, t } from '@superset-ui/core';
import {
ControlPanelConfig,
ControlPanelsContainerProps,
@@ -45,6 +45,7 @@ import {
DEFAULT_FORM_DATA,
TIME_SERIES_DESCRIPTION_TEXT,
} from '../../constants';
import { StackControlsValue } from '../../../constants';
const {
logAxis,
@@ -321,6 +322,38 @@ const config: ControlPanelConfig = {
['color_scheme'],
['time_shift_color'],
...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],
[
{

View File

@@ -191,6 +191,7 @@ export default function transformProps(
yAxisTitleMargin,
yAxisTitlePosition,
zoomable,
stackDimension,
}: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
const refs: Refs = {};
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
const [xAxisMin, xAxisMax] = (xAxisBounds || []).map(parseAxisBound);
let [yAxisMin, yAxisMax] = (yAxisBounds || []).map(parseAxisBound);

View File

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

View File

@@ -0,0 +1,59 @@
/**
* 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 { QueryFormMetric } from '@superset-ui/core';
export const getMetricDisplayName = (
metric: QueryFormMetric,
verboseMap: Record<string, string> = {},
): string => {
// Case 1: Simple string metric - use verboseMap or the string itself
if (typeof metric === 'string') {
return verboseMap[metric] || metric;
}
// Case 2: Metric with explicit label - always prefer this if available
if (metric.label) {
return metric.label;
}
// Case 3: SIMPLE expression type (column with aggregate)
if (metric.expressionType === 'SIMPLE') {
const column = metric.column || {};
const columnName = column.column_name || '';
// Use verbose name from column if available
const displayName = column.verbose_name || columnName;
const aggregate = metric.aggregate || '';
// If the verbose map has this column, use that
if (verboseMap[columnName]) {
return `${aggregate}(${verboseMap[columnName]})`;
}
return `${aggregate}(${displayName})`;
}
// Case 4: SQL expression
if (metric.expressionType === 'SQL') {
return metric.sqlExpression || 'Custom SQL Metric';
}
// Fallback
return 'Unknown Metric';
};

View File

@@ -118,7 +118,9 @@ const chartPropsConfig = {
it('should transform chart props for viz', () => {
const chartProps = new ChartProps(chartPropsConfig);
expect(transformProps(chartProps as EchartsMixedTimeseriesProps)).toEqual(
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
expect(transformed).toEqual(
expect.objectContaining({
echartOptions: expect.objectContaining({
series: expect.arrayContaining([
@@ -127,7 +129,7 @@ it('should transform chart props for viz', () => {
[599616000000, 1],
[599916000000, 3],
],
id: 'boy',
id: 'sum__num (Query A), boy',
stack: 'obs\na',
}),
expect.objectContaining({
@@ -135,15 +137,16 @@ it('should transform chart props for viz', () => {
[599616000000, 2],
[599916000000, 4],
],
id: 'girl',
id: 'sum__num (Query A), girl',
stack: 'obs\na',
}),
// Query B — Bar series
expect.objectContaining({
data: [
[599616000000, 1],
[599916000000, 3],
],
id: 'boy (1)',
id: 'sum__num (Query B), boy',
stack: 'obs\nb',
}),
expect.objectContaining({
@@ -151,7 +154,7 @@ it('should transform chart props for viz', () => {
[599616000000, 2],
[599916000000, 4],
],
id: 'girl (1)',
id: 'sum__num (Query B), girl',
stack: 'obs\nb',
}),
]),

View File

@@ -28,7 +28,7 @@ import type {
CallbackDataParams,
} from 'echarts/types/src/util/types';
import transformProps, { parseParams } from '../../src/Pie/transformProps';
import { EchartsPieChartProps } from '../../src/Pie/types';
import { EchartsPieChartProps, PieChartDataItem } from '../../src/Pie/types';
describe('Pie transformProps', () => {
const formData: SqlaFormData = {
@@ -46,8 +46,13 @@ describe('Pie transformProps', () => {
queriesData: [
{
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');
});
});
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

@@ -0,0 +1,127 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ChartProps, supersetTheme } from '@superset-ui/core';
import { RadarSeriesOption } from 'echarts/charts';
import transformProps from '../../src/Radar/transformProps';
import {
EchartsRadarChartProps,
EchartsRadarFormData,
} from '../../src/Radar/types';
interface RadarIndicator {
name: string;
max: number;
min: number;
}
type RadarShape = 'circle' | 'polygon';
interface RadarChartConfig {
shape: RadarShape;
indicator: RadarIndicator[];
}
interface RadarSeriesData {
value: number[];
name: string;
}
describe('Radar transformProps', () => {
const formData: Partial<EchartsRadarFormData> = {
colorScheme: 'supersetColors',
datasource: '3__table',
granularity_sqla: 'ds',
columnConfig: {
'MAX(na_sales)': {
radarMetricMaxValue: null,
radarMetricMinValue: 0,
},
'SUM(eu_sales)': {
radarMetricMaxValue: 5000,
},
},
groupby: [],
metrics: [
'MAX(na_sales)',
'SUM(jp_sales)',
'SUM(other_sales)',
'SUM(eu_sales)',
],
viz_type: 'radar',
numberFormat: 'SMART_NUMBER',
dateFormat: 'smart_date',
showLegend: true,
showLabels: true,
isCircle: false,
};
const chartProps = new ChartProps({
formData,
width: 800,
height: 600,
queriesData: [
{
data: [
{
'MAX(na_sales)': 41.49,
'SUM(jp_sales)': 1290.99,
'SUM(other_sales)': 797.73,
'SUM(eu_sales)': 2434.13,
},
],
},
],
theme: supersetTheme,
});
it('should transform chart props for normalized radar chart & normalize all metrics except the ones with custom min & max', () => {
const transformedProps = transformProps(
chartProps as EchartsRadarChartProps,
);
const series = transformedProps.echartOptions.series as RadarSeriesOption[];
const radar = transformedProps.echartOptions.radar as RadarChartConfig;
expect((series[0].data as RadarSeriesData[])[0].value).toEqual([
0.0170451044, 0.5303701939, 0.3277269497, 2434.13,
]);
expect(radar.indicator).toEqual([
{
name: 'MAX(na_sales)',
max: 1,
min: 0,
},
{
name: 'SUM(jp_sales)',
max: 1,
min: 0,
},
{
name: 'SUM(other_sales)',
max: 1,
min: 0,
},
{
name: 'SUM(eu_sales)',
max: 5000,
min: 0,
},
]);
});
});

View File

@@ -113,6 +113,10 @@ const StyledSpace = styled(Space)`
}
`;
const StyledRow = styled.div`
display: flex;
`;
// Be sure to pass our updateMyData and the skipReset option
export default typedMemo(function DataTable<D extends object>({
tableClassName,
@@ -447,9 +451,9 @@ export default typedMemo(function DataTable<D extends object>({
>
{hasGlobalControl ? (
<div ref={globalControlRef} className="form-inline dt-controls">
<div className="row">
<StyledRow className="row">
<div
className={renderTimeComparisonDropdown ? 'col-sm-5' : 'col-sm-6'}
className={renderTimeComparisonDropdown ? 'col-sm-4' : 'col-sm-5'}
>
{hasPagination ? (
<SelectPageSize
@@ -466,7 +470,11 @@ export default typedMemo(function DataTable<D extends object>({
) : null}
</div>
{searchInput ? (
<StyledSpace 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>
@@ -500,7 +508,7 @@ export default typedMemo(function DataTable<D extends object>({
{renderTimeComparisonDropdown()}
</div>
) : null}
</div>
</StyledRow>
</div>
) : null}
{wrapStickyTable ? wrapStickyTable(renderTable) : renderTable()}

View File

@@ -79,7 +79,7 @@ import DataTable, {
import Styles from './Styles';
import { formatColumnValue } from './utils/formatValue';
import { PAGE_SIZE_OPTIONS } from './consts';
import { PAGE_SIZE_OPTIONS, SERVER_PAGE_SIZE_OPTIONS } from './consts';
import { updateTableOwnState } from './DataTable/utils/externalAPIs';
import getScrollBarSize from './DataTable/utils/getScrollBarSize';
@@ -306,7 +306,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
// only take relevant page size options
const pageSizeOptions = useMemo(() => {
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,
) as SizeOption[];
}, [data.length, rowCount, serverPagination]);

View File

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

View File

@@ -54,7 +54,7 @@ import {
} from '@superset-ui/core';
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';
function getQueryMode(controls: ControlStateMapping): QueryMode {
@@ -343,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',
@@ -364,7 +384,7 @@ const config: ControlPanelConfig = {
freeForm: true,
label: t('Server Page Length'),
default: 10,
choices: PAGE_SIZE_OPTIONS,
choices: SERVER_PAGE_SIZE_OPTIONS,
description: t('Rows per page, 0 means no pagination'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
Boolean(controls?.server_pagination?.value),
@@ -397,6 +417,7 @@ const config: ControlPanelConfig = {
v,
state?.server_pagination,
state?.maxValueWithoutServerPagination || DEFAULT_MAX_ROW,
state?.maxValue || DEFAULT_MAX_ROW_TABLE_SERVER,
),
],
// Re run the validations when this control value
@@ -412,21 +433,6 @@ 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: isAggMode,
resetOnHide: false,
},
},
],
[
{
name: 'show_totals',

View File

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

View File

@@ -11,37 +11,23 @@
*
* 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
* "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 } from 'spec/helpers/testing-library';
import Tooltip, { getTooltipHTML } from './Tooltip';
import { getTooltipHTML } from './Tooltip';
test('should render a tooltip', () => {
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', () => {
test('getTooltipHTML returns the expected HTML (string inputs)', () => {
const html = getTooltipHTML({
title: 'tooltip title',
icon: <div>icon</div>,
body: <div>body</div>,
meta: 'meta',
footer: <div>footer</div>,
body: 'body text',
footer: 'footer note',
});
expect(html).toContain('tooltip-detail');
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
* under the License.
*/
import { renderToStaticMarkup } from 'react-dom/server';
import { Tag } from 'src/components';
import DOMPurify from 'dompurify';
type Props = {
title: string;
icon?: React.ReactNode;
body?: React.ReactNode;
meta?: string;
footer?: React.ReactNode;
title?: string;
body?: string;
footer?: string;
};
export const Tooltip: React.FC<Props> = ({
title,
icon,
body,
meta,
footer,
}) => (
<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>
)}
export function getTooltipHTML({ title, body, footer }: Props): string {
const html = `
<div class="tooltip-detail">
${title ? `<div class="tooltip-detail-title">${title}</div>` : ''}
${body ? `<div class="tooltip-detail-body">${body}</div>` : ''}
${footer ? `<div class="tooltip-detail-footer">${footer}</div>` : ''}
</div>
{body && <div className="tooltip-detail-body">{body ?? title}</div>}
{footer && <div className="tooltip-detail-footer">{footer}</div>}
</div>
);
export const getTooltipHTML = (props: Props) =>
`${renderToStaticMarkup(<Tooltip {...props} />)}`;
export default Tooltip;
`;
return DOMPurify.sanitize(html);
}

View File

@@ -190,50 +190,37 @@ export default function AsyncAceEditor(
return (
<>
<Global
key="ace-tooltip-global"
styles={css`
.ace_tooltip {
margin-left: ${supersetTheme.gridUnit * 2}px;
padding: 0px;
all: unset;
position: fixed;
z-index: 9999;
background: ${supersetTheme.colors.grayscale.light5};
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 {
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 {
display: flex;
column-gap: ${supersetTheme.gridUnit}px;
font-weight: bold;
font-size: ${supersetTheme.typography.sizes.m}px;
}
& .tooltip-detail-body {
word-break: break-word;
font-size: ${supersetTheme.typography.sizes.s}px;
padding: ${supersetTheme.gridUnit}px;
}
& .tooltip-detail-head,
& .tooltip-detail-body {
padding: ${supersetTheme.gridUnit}px
${supersetTheme.gridUnit * 2}px;
}
& .tooltip-detail-footer {
border-top: 1px ${supersetTheme.colors.grayscale.light2}
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;
}
font-size: ${supersetTheme.typography.sizes.s}px;
}
}
`}

View File

@@ -39,19 +39,21 @@ const PLACEMENTS = [
'topRight',
];
const theme = useTheme();
export const InteractiveIconTooltip = (args: Props) => {
const theme = useTheme();
export const InteractiveIconTooltip = (args: Props) => (
<div
css={css`
margin: ${theme.gridUnit * 10}px ${theme.gridUnit * 17.5}px;
`}
>
<IconTooltip {...args}>
<Icons.InfoCircleOutlined />
</IconTooltip>
</div>
);
return (
<div
css={css`
margin: ${theme.gridUnit * 10}px ${theme.gridUnit * 17.5}px;
`}
>
<IconTooltip {...args}>
<Icons.InfoCircleOutlined />
</IconTooltip>
</div>
);
};
InteractiveIconTooltip.args = {
tooltip: 'Tooltip',

View File

@@ -30,6 +30,7 @@ import {
BarChartOutlined,
BellOutlined,
BookOutlined,
BulbOutlined,
CaretUpOutlined,
CaretDownOutlined,
CaretLeftOutlined,
@@ -61,6 +62,7 @@ import {
EyeOutlined,
EyeInvisibleOutlined,
FallOutlined,
FieldNumberOutlined,
FieldTimeOutlined,
FileImageOutlined,
FileOutlined,
@@ -90,6 +92,7 @@ import {
SaveOutlined,
SearchOutlined,
SettingOutlined,
ShareAltOutlined,
StarOutlined,
StarFilled,
StopOutlined,
@@ -131,6 +134,7 @@ const AntdIcons = {
BarChartOutlined,
BellOutlined,
BookOutlined,
BulbOutlined,
CaretUpOutlined,
CaretDownOutlined,
CaretLeftOutlined,
@@ -162,6 +166,7 @@ const AntdIcons = {
EyeOutlined,
EyeInvisibleOutlined,
FallOutlined,
FieldNumberOutlined,
FieldTimeOutlined,
FileImageOutlined,
FileOutlined,
@@ -191,6 +196,7 @@ const AntdIcons = {
SaveOutlined,
SearchOutlined,
SettingOutlined,
ShareAltOutlined,
StarOutlined,
StarFilled,
StopOutlined,

View File

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

View File

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

View File

@@ -174,4 +174,33 @@ describe('ResultsPaneOnDashboard', () => {
expect(await findByText('Results')).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 PropTypes from 'prop-types';
import { TextArea } from 'src/components/Input';
import {
Tooltip,
TooltipProps as TooltipOptions,
} from 'src/components/Tooltip';
import { Tooltip, TooltipProps } from 'src/components/Tooltip';
import { t, withTheme } from '@superset-ui/core';
import Button from 'src/components/Button';
@@ -59,7 +56,7 @@ const propTypes = {
'vertical',
]),
textAreaStyles: PropTypes.object,
tooltipOptions: PropTypes.oneOf([null, TooltipOptions]),
tooltipOptions: PropTypes.oneOf([null, TooltipProps]),
};
const defaultProps = {

View File

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

View File

@@ -36,11 +36,8 @@ export function getColumnKeywords(columns: ColumnMeta[]) {
value: column_name,
docHTML: getTooltipHTML({
title: column_name,
meta: type ? `column: ${type}` : 'column',
body: `${description ?? ''}`,
footer: is_certified ? (
<>{t('Certified by %s', certified_by)}</>
) : undefined,
body: `type: ${type || 'unknown'}<br />${description ? `description: ${description}` : ''}`,
footer: is_certified ? t('Certified by %s', certified_by) : undefined,
}),
score: COLUMN_AUTOCOMPLETE_SCORE,
meta: 'column',

View File

@@ -91,12 +91,13 @@ describe('AllEntitiesTable', () => {
jest.restoreAllMocks();
});
it('renders when empty', () => {
it('renders when empty with button to tag if user has perm', () => {
render(
<AllEntitiesTable
search=""
setShowTagModal={mockSetShowTagModal}
objects={mockObjects}
canEditTag
/>,
{ useRouter: true },
);
@@ -108,25 +109,70 @@ describe('AllEntitiesTable', () => {
expect(screen.getByText('Add tag to entities')).toBeInTheDocument();
});
it('renders when empty without button to tag if user does not have perm', () => {
render(
<AllEntitiesTable
search=""
setShowTagModal={mockSetShowTagModal}
objects={mockObjects}
canEditTag={false}
/>,
{ useRouter: true },
);
expect(
screen.getByText('No entities have this tag currently assigned'),
).toBeInTheDocument();
expect(screen.queryByText('Add tag to entities')).not.toBeInTheDocument();
});
it('renders the correct tags for each object type, excluding the current tag', () => {
render(
<AllEntitiesTable
search=""
setShowTagModal={mockSetShowTagModal}
objects={mockObjectsWithTags}
canEditTag
/>,
{ useRouter: true },
);
expect(screen.getByText('Dashboards')).toBeInTheDocument();
expect(screen.getByText('Sales Dashboard')).toBeInTheDocument();
expect(screen.getByText('Sales')).toBeInTheDocument();
expect(screen.getByText('Charts')).toBeInTheDocument();
expect(screen.getByText('Monthly Revenue')).toBeInTheDocument();
expect(screen.getByText('Revenue')).toBeInTheDocument();
expect(screen.getByText('Queries')).toBeInTheDocument();
expect(screen.getByText('User Engagement')).toBeInTheDocument();
expect(screen.getByText('Engagement')).toBeInTheDocument();
expect(screen.queryByText('Current Tag')).not.toBeInTheDocument();
});
it('Only list asset types that have entities', () => {
const mockObjects = {
dashboard: [],
chart: [mockObjectsWithTags.chart[0]],
query: [],
};
render(
<AllEntitiesTable
search=""
setShowTagModal={mockSetShowTagModal}
objects={mockObjects}
canEditTag
/>,
{ useRouter: true },
);
expect(screen.queryByText('Dashboards')).not.toBeInTheDocument();
expect(screen.getByText('Charts')).toBeInTheDocument();
expect(screen.getByText('Monthly Revenue')).toBeInTheDocument();
expect(screen.queryByText('Queries')).not.toBeInTheDocument();
});
});

View File

@@ -53,20 +53,22 @@ interface AllEntitiesTableProps {
search?: string;
setShowTagModal: (show: boolean) => void;
objects: TaggedObjects;
canEditTag: boolean;
}
export default function AllEntitiesTable({
search = '',
setShowTagModal,
objects,
canEditTag,
}: AllEntitiesTableProps) {
type objectType = 'dashboard' | 'chart' | 'query';
const [tagId] = useQueryParam('id', NumberParam);
const showListViewObjs =
objects.dashboard.length > 0 ||
objects.chart.length > 0 ||
objects.query.length > 0;
const showDashboardList = objects.dashboard.length > 0;
const showChartList = objects.chart.length > 0;
const showQueryList = objects.query.length > 0;
const showListViewObjs = showDashboardList || showChartList || showQueryList;
const renderTable = (type: objectType) => {
const data = objects[type].map((o: TaggedObject) => ({
@@ -134,20 +136,34 @@ export default function AllEntitiesTable({
<AllEntitiesTableContainer>
{showListViewObjs ? (
<>
<div className="entity-title">{t('Dashboards')}</div>
{renderTable('dashboard')}
<div className="entity-title">{t('Charts')}</div>
{renderTable('chart')}
<div className="entity-title">{t('Queries')}</div>
{renderTable('query')}
{showDashboardList && (
<>
<div className="entity-title">{t('Dashboards')}</div>
{renderTable('dashboard')}
</>
)}
{showChartList && (
<>
<div className="entity-title">{t('Charts')}</div>
{renderTable('chart')}
</>
)}
{showQueryList && (
<>
<div className="entity-title">{t('Queries')}</div>
{renderTable('query')}
</>
)}
</>
) : (
<EmptyState
image="dashboard.svg"
size="large"
title={t('No entities have this tag currently assigned')}
buttonAction={() => setShowTagModal(true)}
buttonText={t('Add tag to entities')}
{...(canEditTag && {
buttonAction: () => setShowTagModal(true),
buttonText: t('Add tag to entities'),
})}
/>
)}
</AllEntitiesTableContainer>

View File

@@ -0,0 +1,194 @@
/**
* 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 {
act,
fireEvent,
render,
screen,
waitFor,
} from 'spec/helpers/testing-library';
import { t } from '@superset-ui/core';
import * as ace from 'ace-builds';
import ExtraOptions from './ExtraOptions';
import { DatabaseObject } from '../types';
const defaultDb = {
expose_in_sqllab: true,
allow_ctas: false,
allow_cvas: false,
allow_dml: false,
allow_run_async: false,
cache_timeout: 300,
force_ctas_schema: 'public',
masked_encrypted_extra: '',
server_cert: '',
impersonate_user: false,
extra: JSON.stringify({
cost_estimate_enabled: false,
allows_virtual_table_explore: true,
disable_data_preview: false,
schema_options: { expand_rows: false },
metadata_cache_timeout: {
schema_cache_timeout: 600,
table_cache_timeout: 1200,
},
disable_drill_to_detail: false,
metadata_params: {},
engine_params: {},
version: '',
cancel_query_on_windows_unload: false,
}),
engine_information: {
supports_file_upload: true,
supports_dynamic_catalog: true,
},
configuration_method: '', // added dummy value for configuration_method
database_name: 'Test Database', // added dummy value for database_name
driver: 'sqlite', // added dummy value for driver
id: 1, // added dummy value for id
sqlalchemy_uri: 'sqlite:///:memory:', // added dummy value for sqlalchemy_uri
parameters: {}, // added dummy value for parameters
};
describe('ExtraOptions Component', () => {
const onInputChange = jest.fn();
const onTextChange = jest.fn();
const onEditorChange = jest.fn();
const onExtraInputChange = jest.fn();
const onExtraEditorChange = jest.fn();
const renderComponent = (dbProps = defaultDb, extension = undefined) =>
render(
<ExtraOptions
db={dbProps as unknown as DatabaseObject}
onInputChange={onInputChange}
onTextChange={onTextChange}
onEditorChange={onEditorChange}
onExtraInputChange={onExtraInputChange}
onExtraEditorChange={onExtraEditorChange}
extraExtension={extension}
/>,
);
beforeEach(() => {
jest.clearAllMocks();
});
it('renders all main panels', () => {
renderComponent();
expect(screen.getByText(t('SQL Lab'))).toBeInTheDocument();
expect(screen.getByText(t('Performance'))).toBeInTheDocument();
expect(screen.getByText(t('Security'))).toBeInTheDocument();
expect(screen.getByText(t('Other'))).toBeInTheDocument();
});
it('calls onInputChange when "Expose database in SQL Lab" checkbox is clicked', () => {
renderComponent();
const sqlLabText = screen.getByText(t('SQL Lab'));
fireEvent.click(sqlLabText);
const checkbox = screen.getByLabelText(t('Expose database in SQL Lab'));
fireEvent.click(checkbox);
expect(onInputChange).toHaveBeenCalled();
});
it('calls onExtraInputChange when "Enable query cost estimation" checkbox is clicked', () => {
renderComponent();
const sqlLabText = screen.getByText(t('SQL Lab'));
fireEvent.click(sqlLabText);
const checkbox = screen.getByLabelText(t('Enable query cost estimation'));
fireEvent.click(checkbox);
expect(onExtraInputChange).toHaveBeenCalled();
});
it('calls onExtraEditorChange when metadata_params json editor changes', async () => {
renderComponent();
// Click to open the editor tab/section
const otherHeader = screen.getByText(t('Other'));
fireEvent.click(otherHeader);
// Wait for Ace to initialize (in case it's async)
await waitFor(() => {
expect(document.querySelector('#metadata_params')).toBeInTheDocument();
});
// Grab editor instance by ID or name
const editorInstance = ace.edit('metadata_params');
act(() => {
editorInstance.setValue('{"key":"value"}');
});
expect(onExtraEditorChange).toHaveBeenCalledWith({
json: '{"key":"value"}',
name: 'metadata_params',
});
act(() => {
editorInstance.setValue('foo');
});
expect(onExtraEditorChange).toHaveBeenCalledWith({
json: 'foo',
name: 'metadata_params',
});
// it accepts invalid json strings
act(() => {
editorInstance.setValue('{"key":"value');
});
expect(onExtraEditorChange).toHaveBeenCalledWith({
json: '{"key":"value',
name: 'metadata_params',
});
});
it('calls onTextChange when server certificate textarea is changed', () => {
renderComponent();
// Click to open the security tab/section
const securityHeader = screen.getByText(t('Security'));
fireEvent.click(securityHeader);
const textarea = screen.getByPlaceholderText(t('Enter CA_BUNDLE'));
fireEvent.change(textarea, { target: { value: 'new cert' } });
expect(onTextChange).toHaveBeenCalled();
});
it('handles input change for schema cache timeout', () => {
renderComponent();
const performanceHeader = screen.getByText(t('Performance'));
fireEvent.click(performanceHeader);
const input = screen.getByTestId('schema-cache-timeout-test');
fireEvent.change(input, { target: { value: '500' } });
expect(onExtraInputChange).toHaveBeenCalled();
});
it('handles input change for table cache timeout', () => {
renderComponent();
const performanceHeader = screen.getByText(t('Performance'));
fireEvent.click(performanceHeader);
const input = screen.getByTestId('table-cache-timeout-test');
fireEvent.change(input, { target: { value: '1000' } });
expect(onExtraInputChange).toHaveBeenCalled();
});
});

View File

@@ -271,6 +271,7 @@ const ExtraOptions = ({
value={db?.cache_timeout || ''}
placeholder={t('Enter duration in seconds')}
onChange={onInputChange}
data-test="cache-timeout-test"
/>
</div>
<div className="helper">
@@ -533,7 +534,9 @@ const ExtraOptions = ({
value={
!Object.keys(extraJson?.metadata_params || {}).length
? ''
: extraJson?.metadata_params
: typeof extraJson?.metadata_params === 'string'
? extraJson?.metadata_params
: JSON.stringify(extraJson?.metadata_params)
}
/>
</div>

View File

@@ -0,0 +1,31 @@
/**
* 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 BaseGroupListModalProps {
show: boolean;
onHide: () => void;
onSave: () => void;
}
export interface FormValues {
name: string;
label?: string;
description?: string;
roles: number[];
users: { value: number; label: string }[];
}

View File

@@ -0,0 +1,74 @@
/**
* 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 { SupersetClient, t } from '@superset-ui/core';
import rison from 'rison';
import { FormValues } from './types';
export const createGroup = async (values: FormValues) => {
await SupersetClient.post({
endpoint: '/api/v1/security/groups/',
jsonPayload: { ...values, users: values.users.map(user => user.value) },
});
};
export const updateGroup = async (groupId: number, values: FormValues) => {
await SupersetClient.put({
endpoint: `/api/v1/security/groups/${groupId}`,
jsonPayload: { ...values, users: values.users.map(user => user.value) },
});
};
export const deleteGroup = async (groupId: number) =>
SupersetClient.delete({
endpoint: `/api/v1/security/groups/${groupId}`,
});
export const fetchUserOptions = async (
filterValue: string,
page: number,
pageSize: number,
addDangerToast: (msg: string) => void,
) => {
const query = rison.encode({
filter: filterValue,
page,
page_size: pageSize,
order_column: 'username',
order_direction: 'asc',
});
try {
const response = await SupersetClient.get({
endpoint: `/api/v1/security/users/?q=${query}`,
});
const results = response.json?.result || [];
return {
data: results.map((user: any) => ({
value: user.id,
label: user.username,
})),
totalCount: response.json?.count ?? 0,
};
} catch (error) {
addDangerToast(t('There was an error while fetching users'));
return { data: [], totalCount: 0 };
}
};

View File

@@ -335,7 +335,13 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
return;
}
// Case 2: Handle the default to first Value case
if (filterState.value !== undefined) {
// Set the filter state value if it is defined
updateDataMask(filterState.value);
return;
}
// Handle the default to first Value case
if (defaultToFirstItem) {
// Set to first item if defaultToFirstItem is true
const firstItem: SelectValue = data[0]
@@ -345,7 +351,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
updateDataMask(firstItem);
}
} else if (formData?.defaultValue) {
// Case 3 : Handle defalut value case
// Handle defalut value case
updateDataMask(formData.defaultValue);
}
}, [

View File

@@ -0,0 +1,282 @@
/**
* 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 { useMemo } from 'react';
import { t, css } from '@superset-ui/core';
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
import { useListViewResource } from 'src/views/CRUD/hooks';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import ListView, { Filters, FilterOperator } from 'src/components/ListView';
// eslint-disable-next-line no-restricted-imports
import { Typography } from 'antd-v5';
import { fetchUserOptions } from 'src/features/groups/utils';
export type ActionLogObject = {
user: {
username: string;
};
action: string;
dttm: string | null;
dashboard_id?: number;
slice_id?: number;
json?: string;
duration_ms?: number;
referrer?: string;
};
const PAGE_SIZE = 25;
function ActionLogList() {
const { addDangerToast, addSuccessToast } = useToasts();
const initialSort = [{ id: 'dttm', desc: true }];
const subMenuButtons: SubMenuProps['buttons'] = [];
const {
state: {
loading,
resourceCount: LogsCount,
resourceCollection: Logs,
bulkSelectEnabled,
},
fetchData,
refreshData,
toggleBulkSelect,
} = useListViewResource<ActionLogObject>(
'log',
t('Log'),
addDangerToast,
false,
);
const filters: Filters = useMemo(
() => [
{
Header: t('Users'),
key: 'user',
id: 'user',
input: 'select',
operator: FilterOperator.RelationOneMany,
unfilteredLabel: t('All'),
fetchSelects: async (filterValue, page, pageSize) =>
fetchUserOptions(filterValue, page, pageSize, addDangerToast),
},
{
Header: t('Dashboard Id'),
key: 'dashboard_id',
id: 'dashboard_id',
input: 'search',
operator: FilterOperator.Equals,
},
{
Header: t('Slice Id'),
key: 'slice_id',
id: 'slice_id',
input: 'search',
operator: FilterOperator.Equals,
},
{
Header: t('Action'),
key: 'action',
id: 'action',
input: 'search',
operator: FilterOperator.Contains,
},
{
Header: t('JSON'),
key: 'json',
id: 'json',
input: 'search',
operator: FilterOperator.Contains,
},
{
Header: t('dttm'),
key: 'dttm',
id: 'dttm',
input: 'datetime_range',
operator: FilterOperator.Between,
dateFilterValueType: 'iso',
},
{
Header: t('Referrer'),
key: 'referrer',
id: 'referrer',
input: 'search',
operator: FilterOperator.Equals,
},
{
Header: t('Duration Ms'),
key: 'duration_ms',
id: 'duration_ms',
input: 'search',
operator: FilterOperator.Equals,
},
],
[],
);
const columns = useMemo(
() => [
{
accessor: 'action',
Header: t('Action'),
Cell: ({
row: {
original: { action },
},
}: any) => <span>{action}</span>,
},
{
accessor: 'user',
Header: t('User'),
Cell: ({
row: {
original: { user },
},
}: any) => <span>{user?.username}</span>,
},
{
accessor: 'duration_ms',
Header: t('Duration Ms'),
Cell: ({
row: {
original: { duration_ms },
},
}: any) => <span>{duration_ms}</span>,
},
{
accessor: 'dashboard_id',
Header: t('Dashboard Id'),
hidden: false,
Cell: ({
row: {
original: { dashboard_id },
},
}: any) => <span>{dashboard_id}</span>,
},
{
accessor: 'slice_id',
Header: t('Slice Id'),
hidden: false,
Cell: ({
row: {
original: { slice_id },
},
}: any) => <span>{slice_id}</span>,
},
{
accessor: 'json',
Header: t('JSON'),
Cell: ({
row: {
original: { json },
},
}: any) => (
<Typography.Text
css={css`
.antd5-typography-copy {
visibility: hidden;
}
&:hover .antd5-typography-copy {
visibility: visible;
}
`}
copyable={!!json}
ellipsis={{
tooltip: { styles: { root: { maxWidth: '900px' } }, title: json },
}}
>
{json}
</Typography.Text>
),
},
{
accessor: 'referrer',
Header: t('Referrer'),
Cell: ({
row: {
original: { referrer },
},
}: any) => (
<Typography.Text
css={css`
.antd5-typography-copy {
visibility: hidden;
}
&:hover .antd5-typography-copy {
visibility: visible;
}
`}
copyable={!!referrer}
ellipsis={{
tooltip: {
styles: { root: { maxWidth: '580px' } },
title: referrer,
},
}}
>
{referrer}
</Typography.Text>
),
},
{
accessor: 'dttm',
Header: t('Dttm'),
Cell: ({
row: {
original: { dttm },
},
}: any) => <span>{dttm}</span>,
},
],
[],
);
const emptyState = {
title: t('No Logs yet'),
image: 'filter-results.svg',
};
return (
<>
<SubMenu name={t('Action Logs')} buttons={subMenuButtons} />
<ListView<ActionLogObject>
className="action-log-view"
columns={columns}
count={LogsCount}
data={Logs}
fetchData={fetchData}
filters={filters}
initialSort={initialSort}
loading={loading}
pageSize={PAGE_SIZE}
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={toggleBulkSelect}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
emptyState={emptyState}
refreshData={refreshData}
/>
</>
);
}
export default ActionLogList;

View File

@@ -35,6 +35,9 @@ import { fetchObjectsByTagIds, fetchSingleTag } from 'src/features/tags/tags';
import Loading from 'src/components/Loading';
import getOwnerName from 'src/utils/getOwnerName';
import { TaggedObject, TaggedObjects } from 'src/types/TaggedObject';
import { findPermission } from 'src/utils/findPermission';
import { useSelector } from 'react-redux';
import { RootState } from 'src/dashboard/types';
const additionalItemsStyles = (theme: SupersetTheme) => css`
display: flex;
@@ -100,6 +103,10 @@ function AllEntities() {
query: [],
});
const canEditTag = useSelector((state: RootState) =>
findPermission('can_write', 'Tag', state.user?.roles),
);
const editableTitleProps = {
title: tag?.name || '',
placeholder: 'testing',
@@ -211,14 +218,16 @@ function AllEntities() {
}
rightPanelAdditionalItems={
<>
<Button
data-test="bulk-select-action"
buttonStyle="secondary"
onClick={() => setShowTagModal(true)}
showMarginRight={false}
>
{t('Edit Tag')}{' '}
</Button>
{canEditTag && (
<Button
data-test="bulk-select-action"
buttonStyle="secondary"
onClick={() => setShowTagModal(true)}
showMarginRight={false}
>
{t('Edit tag')}{' '}
</Button>
)}
</>
}
menuDropdownProps={{
@@ -232,6 +241,7 @@ function AllEntities() {
search={tag?.name || ''}
setShowTagModal={setShowTagModal}
objects={objects}
canEditTag={canEditTag}
/>
</div>
</AllEntitiesContainer>

View File

@@ -137,6 +137,9 @@ const RolesList = lazy(
const UsersList: LazyExoticComponent<any> = lazy(
() => import(/* webpackChunkName: "UsersList" */ 'src/pages/UsersList'),
);
const ActionLogList: LazyExoticComponent<any> = lazy(
() => import(/* webpackChunkName: "ActionLogList" */ 'src/pages/ActionLog'),
);
type Routes = {
path: string;
@@ -240,6 +243,10 @@ export const routes: Routes = [
path: '/sqllab/',
Component: SqlLab,
},
{
path: '/actionlog/list',
Component: ActionLogList,
},
];
if (isFeatureEnabled(FeatureFlag.TaggingSystem)) {

View File

@@ -31,8 +31,8 @@
"@types/ws": "^8.5.12",
"@typescript-eslint/eslint-plugin": "^8.26.0",
"@typescript-eslint/parser": "^8.29.0",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint": "^9.27.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-lodash": "^8.0.0",
"globals": "^16.0.0",
"jest": "^29.7.0",
@@ -747,12 +747,13 @@
}
},
"node_modules/@eslint/config-array": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz",
"integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==",
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz",
"integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^2.1.5",
"@eslint/object-schema": "^2.1.6",
"debug": "^4.3.1",
"minimatch": "^3.1.2"
},
@@ -760,11 +761,22 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/core": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.1.tgz",
"integrity": "sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==",
"node_modules/@eslint/config-helpers": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz",
"integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/core": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz",
"integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
@@ -773,10 +785,11 @@
}
},
"node_modules/@eslint/eslintrc": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz",
"integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
"integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^6.12.4",
"debug": "^4.3.2",
@@ -799,13 +812,15 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
"dev": true,
"license": "Python-2.0"
},
"node_modules/@eslint/eslintrc/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
@@ -818,6 +833,7 @@
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
@@ -826,30 +842,36 @@
}
},
"node_modules/@eslint/js": {
"version": "9.25.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.25.1.tgz",
"integrity": "sha512-dEIwmjntEx8u3Uvv+kr3PDeeArL8Hw07H9kyYxCjnM9pBjfEhk6uLXSchxxzgiwtRhhzVzqmUSDFBOi1TuZ7qg==",
"version": "9.27.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz",
"integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
}
},
"node_modules/@eslint/object-schema": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz",
"integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==",
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
"integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.4.tgz",
"integrity": "sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==",
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz",
"integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.14.0",
"levn": "^0.4.1"
},
"engines": {
@@ -905,10 +927,11 @@
}
},
"node_modules/@humanwhocodes/retry": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz",
"integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==",
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18"
},
@@ -2527,6 +2550,7 @@
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
@@ -2536,6 +2560,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -3166,21 +3191,23 @@
}
},
"node_modules/eslint": {
"version": "9.17.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz",
"integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==",
"version": "9.27.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz",
"integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.19.0",
"@eslint/core": "^0.9.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "9.17.0",
"@eslint/plugin-kit": "^0.2.3",
"@eslint/config-array": "^0.20.0",
"@eslint/config-helpers": "^0.2.1",
"@eslint/core": "^0.14.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.27.0",
"@eslint/plugin-kit": "^0.3.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
"@types/json-schema": "^7.0.15",
"ajv": "^6.12.4",
@@ -3188,7 +3215,7 @@
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^8.2.0",
"eslint-scope": "^8.3.0",
"eslint-visitor-keys": "^4.2.0",
"espree": "^10.3.0",
"esquery": "^1.5.0",
@@ -3225,13 +3252,17 @@
}
},
"node_modules/eslint-config-prettier": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"version": "10.1.5",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz",
"integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==",
"dev": true,
"license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
"funding": {
"url": "https://opencollective.com/eslint-config-prettier"
},
"peerDependencies": {
"eslint": ">=7.0.0"
}
@@ -3253,10 +3284,11 @@
}
},
"node_modules/eslint-scope": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz",
"integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz",
"integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
@@ -3280,16 +3312,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/@eslint/js": {
"version": "9.17.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz",
"integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/eslint/node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -3409,6 +3431,7 @@
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
"integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.14.0",
"acorn-jsx": "^5.3.2",
@@ -3426,6 +3449,7 @@
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -3464,6 +3488,7 @@
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"estraverse": "^5.2.0"
},
@@ -3552,7 +3577,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.2",
@@ -3885,10 +3911,11 @@
}
},
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
"integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"parent-module": "^1.0.0",
"resolve-from": "^4.0.0"
@@ -3905,6 +3932,7 @@
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
@@ -5407,7 +5435,8 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
@@ -5826,6 +5855,7 @@
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"callsites": "^3.0.0"
},
@@ -5993,6 +6023,7 @@
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
@@ -6812,6 +6843,7 @@
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
}
@@ -7567,29 +7599,35 @@
"dev": true
},
"@eslint/config-array": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz",
"integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==",
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz",
"integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==",
"dev": true,
"requires": {
"@eslint/object-schema": "^2.1.5",
"@eslint/object-schema": "^2.1.6",
"debug": "^4.3.1",
"minimatch": "^3.1.2"
}
},
"@eslint/config-helpers": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz",
"integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==",
"dev": true
},
"@eslint/core": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.1.tgz",
"integrity": "sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==",
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz",
"integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==",
"dev": true,
"requires": {
"@types/json-schema": "^7.0.15"
}
},
"@eslint/eslintrc": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz",
"integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
"integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
"dev": true,
"requires": {
"ajv": "^6.12.4",
@@ -7627,23 +7665,24 @@
}
},
"@eslint/js": {
"version": "9.25.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.25.1.tgz",
"integrity": "sha512-dEIwmjntEx8u3Uvv+kr3PDeeArL8Hw07H9kyYxCjnM9pBjfEhk6uLXSchxxzgiwtRhhzVzqmUSDFBOi1TuZ7qg==",
"version": "9.27.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz",
"integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==",
"dev": true
},
"@eslint/object-schema": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz",
"integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==",
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
"integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
"dev": true
},
"@eslint/plugin-kit": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.4.tgz",
"integrity": "sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==",
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz",
"integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==",
"dev": true,
"requires": {
"@eslint/core": "^0.14.0",
"levn": "^0.4.1"
}
},
@@ -7678,9 +7717,9 @@
"dev": true
},
"@humanwhocodes/retry": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz",
"integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==",
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"dev": true
},
"@istanbuljs/load-nyc-config": {
@@ -9359,21 +9398,22 @@
"dev": true
},
"eslint": {
"version": "9.17.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz",
"integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==",
"version": "9.27.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz",
"integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==",
"dev": true,
"requires": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.19.0",
"@eslint/core": "^0.9.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "9.17.0",
"@eslint/plugin-kit": "^0.2.3",
"@eslint/config-array": "^0.20.0",
"@eslint/config-helpers": "^0.2.1",
"@eslint/core": "^0.14.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.27.0",
"@eslint/plugin-kit": "^0.3.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
"@types/json-schema": "^7.0.15",
"ajv": "^6.12.4",
@@ -9381,7 +9421,7 @@
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^8.2.0",
"eslint-scope": "^8.3.0",
"eslint-visitor-keys": "^4.2.0",
"espree": "^10.3.0",
"esquery": "^1.5.0",
@@ -9400,12 +9440,6 @@
"optionator": "^0.9.3"
},
"dependencies": {
"@eslint/js": {
"version": "9.17.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz",
"integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==",
"dev": true
},
"escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -9481,9 +9515,9 @@
}
},
"eslint-config-prettier": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"version": "10.1.5",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz",
"integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==",
"dev": true,
"requires": {}
},
@@ -9497,9 +9531,9 @@
}
},
"eslint-scope": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz",
"integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz",
"integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==",
"dev": true,
"requires": {
"esrecurse": "^4.3.0",
@@ -9875,9 +9909,9 @@
"dev": true
},
"import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
"integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true,
"requires": {
"parent-module": "^1.0.0",

View File

@@ -39,8 +39,8 @@
"@types/ws": "^8.5.12",
"@typescript-eslint/eslint-plugin": "^8.26.0",
"@typescript-eslint/parser": "^8.29.0",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint": "^9.27.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-lodash": "^8.0.0",
"globals": "^16.0.0",
"jest": "^29.7.0",

View File

@@ -102,7 +102,7 @@ if (startServer && opts.jwtSecret.length < 32) {
if (startServer && opts.jwtSecret.startsWith('CHANGE-ME')) {
console.warn(
'WARNING: it appears you secret in your config.json is insecure',
'WARNING: it appears your secret in your config.json is insecure',
);
console.warn('DO NOT USE IN PRODUCTION');
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,8 @@
},
"dependencies": {
"cookie-parser": "~1.4.7",
"debug": "~4.4.0",
"express": "~4.21.2",
"debug": "~4.4.1",
"express": "~5.1.0",
"http-errors": "~2.0.0",
"jsonwebtoken": "^9.0.2",
"morgan": "~1.10.0",

View File

@@ -92,7 +92,7 @@ class ExecuteSqlCommand(BaseCommand):
pass
@transaction()
def run( # pylint: disable=too-many-statements,useless-suppression
def run(
self,
) -> CommandResult:
"""Runs arbitrary sql and returns data as json"""

View File

@@ -27,7 +27,7 @@ from superset.commands.base import BaseCommand
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetErrorException, SupersetSecurityException
from superset.models.sql_lab import Query
from superset.sql_parse import ParsedQuery
from superset.sql.parse import SQLScript
from superset.sqllab.limiting_factor import LimitingFactor
from superset.utils import core as utils, csv
from superset.views.utils import _deserialize_results_payload
@@ -115,10 +115,9 @@ class SqlResultExportCommand(BaseCommand):
limit = None
else:
sql = self._query.executed_sql
limit = ParsedQuery(
sql,
engine=self._query.database.db_engine_spec.engine,
).limit
script = SQLScript(sql, self._query.database.db_engine_spec.engine)
# when a query has multiple statements only the last one returns data
limit = script.statements[-1].get_limit_value()
if limit is not None and self._query.limiting_factor in {
LimitingFactor.QUERY,
LimitingFactor.DROPDOWN,

View File

@@ -765,7 +765,7 @@ SCREENSHOT_WAIT_FOR_ERROR_MODAL_INVISIBLE = 5
SCREENSHOT_PLAYWRIGHT_WAIT_EVENT = "load"
# Default timeout for Playwright browser context for all operations
SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT = int(
timedelta(seconds=30).total_seconds() * 1000
timedelta(seconds=60).total_seconds() * 1000
)
# ---------------------------------------------------
@@ -1299,7 +1299,7 @@ TRACKING_URL_TRANSFORMER = lambda url: url # noqa: E731
DB_POLL_INTERVAL_SECONDS: dict[str, int] = {}
# Interval between consecutive polls when using Presto Engine
# See here: https://github.com/dropbox/PyHive/blob/8eb0aeab8ca300f3024655419b93dad926c1a351/pyhive/presto.py#L93 # pylint: disable=line-too-long,useless-suppression # noqa: E501
# See here: https://github.com/dropbox/PyHive/blob/8eb0aeab8ca300f3024655419b93dad926c1a351/pyhive/presto.py#L93 # noqa: E501
PRESTO_POLL_INTERVAL = int(timedelta(seconds=1).total_seconds())
# Allow list of custom authentications for each DB engine.

View File

@@ -39,7 +39,6 @@ from uuid import uuid4
import pandas as pd
import requests
import sqlparse
from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
from deprecation import deprecated
@@ -55,17 +54,21 @@ from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.engine.url import URL
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.sql import literal_column, quoted_name, text
from sqlalchemy.sql.expression import ColumnClause, Select, TextAsFrom, TextClause
from sqlalchemy.sql.expression import ColumnClause, Select, TextClause
from sqlalchemy.types import TypeEngine
from sqlparse.tokens import CTE
from superset import db, sql_parse
from superset.constants import QUERY_CANCEL_KEY, TimeGrain as TimeGrainConstants
from superset.databases.utils import get_table_metadata, make_url_safe
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import DisallowedSQLFunction, OAuth2Error, OAuth2RedirectError
from superset.sql.parse import BaseSQLStatement, SQLScript, Table
from superset.sql_parse import ParsedQuery
from superset.sql.parse import (
BaseSQLStatement,
LimitMethod,
SQLScript,
SQLStatement,
Table,
)
from superset.superset_typing import (
OAuth2ClientConfig,
OAuth2State,
@@ -166,14 +169,6 @@ def compile_timegrain_expression(
return element.name.replace("{col}", compiler.process(element.col, **kwargs))
class LimitMethod: # pylint: disable=too-few-public-methods
"""Enum the ways that limits can be applied"""
FETCH_MANY = "fetch_many"
WRAP_SQL = "wrap_sql"
FORCE_LIMIT = "force_limit"
class MetricType(TypedDict, total=False):
"""
Type for metrics return by `get_metrics`.
@@ -377,16 +372,9 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
allows_cte_in_subquery = True
# Define alias for CTE
cte_alias = "__cte"
# Whether allow LIMIT clause in the SQL
# If True, then the database engine is allowed for LIMIT clause
# If False, then the database engine is allowed for TOP clause
allow_limit_clause = True
# This set will give keywords for select statements
# to consider for the engines with TOP SQL parsing
select_keywords: set[str] = {"SELECT"}
# This set will give the keywords for data limit statements
# to consider for the engines with TOP SQL parsing
top_keywords: set[str] = {"TOP"}
# A set of disallowed connection query parameters by driver name
disallow_uri_query_params: dict[str, set[str]] = {}
# A Dict of query parameters that will always be used on every connection
@@ -1119,100 +1107,6 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
return {}
@classmethod
def apply_limit_to_sql(
cls, sql: str, limit: int, database: Database, force: bool = False
) -> str:
"""
Alters the SQL statement to apply a LIMIT clause
:param sql: SQL query
:param limit: Maximum number of rows to be returned by the query
:param database: Database instance
:return: SQL query with limit clause
"""
if cls.limit_method == LimitMethod.WRAP_SQL:
sql = sql.strip("\t\n ;")
qry = (
select("*")
.select_from(TextAsFrom(text(sql), ["*"]).alias("inner_qry"))
.limit(limit)
)
return database.compile_sqla_query(qry)
if cls.limit_method == LimitMethod.FORCE_LIMIT:
parsed_query = sql_parse.ParsedQuery(sql, engine=cls.engine)
sql = parsed_query.set_or_update_query_limit(limit, force=force)
return sql
@classmethod
def apply_top_to_sql(cls, sql: str, limit: int) -> str: # noqa: C901
"""
Alters the SQL statement to apply a TOP clause
:param limit: Maximum number of rows to be returned by the query
:param sql: SQL query
:return: SQL query with top clause
"""
cte = None
sql_remainder = None
sql = sql.strip(" \t\n;")
query_limit: int | None = sql_parse.extract_top_from_query(
sql, cls.top_keywords
)
if not limit:
final_limit = query_limit
elif int(query_limit or 0) < limit and query_limit is not None:
final_limit = query_limit
else:
final_limit = limit
if not cls.allows_cte_in_subquery:
cte, sql_remainder = sql_parse.get_cte_remainder_query(sql)
if cte:
str_statement = str(sql_remainder)
cte = cte + "\n"
else:
cte = ""
str_statement = str(sql)
str_statement = str_statement.replace("\n", " ").replace("\r", "")
tokens = str_statement.rstrip().split(" ")
tokens = [token for token in tokens if token]
if cls.top_not_in_sql(str_statement):
selects = [
i
for i, word in enumerate(tokens)
if word.upper() in cls.select_keywords
]
first_select = selects[0]
if tokens[first_select + 1].upper() == "DISTINCT":
first_select += 1
tokens.insert(first_select + 1, "TOP")
tokens.insert(first_select + 2, str(final_limit))
next_is_limit_token = False
new_tokens = []
for token in tokens:
if token in cls.top_keywords:
next_is_limit_token = True
elif next_is_limit_token:
if token.isdigit():
token = str(final_limit)
next_is_limit_token = False
new_tokens.append(token)
sql = " ".join(new_tokens)
return cte + sql
@classmethod
def top_not_in_sql(cls, sql: str) -> bool:
for top_word in cls.top_keywords:
if top_word.upper() in sql.upper():
return False
return True
@classmethod
def get_limit_from_sql(cls, sql: str) -> int | None:
"""
@@ -1221,20 +1115,8 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
:param sql: SQL query
:return: Value of limit clause in query
"""
parsed_query = sql_parse.ParsedQuery(sql, engine=cls.engine)
return parsed_query.limit
@classmethod
def set_or_update_query_limit(cls, sql: str, limit: int) -> str:
"""
Create a query based on original query but with new limit clause
:param sql: SQL query
:param limit: New limit to insert/replace into query
:return: Query with new limit
"""
parsed_query = sql_parse.ParsedQuery(sql, engine=cls.engine)
return parsed_query.set_or_update_query_limit(limit)
script = SQLScript(sql, engine=cls.engine)
return script.statements[-1].get_limit_value()
@classmethod
def get_cte_query(cls, sql: str) -> str | None:
@@ -1246,18 +1128,9 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
"""
if not cls.allows_cte_in_subquery:
stmt = sqlparse.parse(sql)[0]
# The first meaningful token for CTE will be with WITH
idx, token = stmt.token_next(-1, skip_ws=True, skip_cm=True)
if not (token and token.ttype == CTE):
return None
idx, token = stmt.token_next(idx)
idx = stmt.token_index(token) + 1
# extract rest of the SQLs after CTE
remainder = "".join(str(token) for token in stmt.tokens[idx:]).strip()
return f"WITH {token.value},\n{cls.cte_alias} AS (\n{remainder}\n)"
statement = SQLStatement(sql, engine=cls.engine)
if statement.has_cte():
return statement.as_cte(cls.cte_alias).format()
return None
@@ -1686,8 +1559,7 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
full_table_name = cls.quote_table(table, engine.dialect)
qry = select(fields).select_from(text(full_table_name))
if limit and cls.allow_limit_clause:
qry = qry.limit(limit)
qry = qry.limit(limit)
if latest_partition:
partition_query = cls.where_latest_partition(
database,
@@ -2088,14 +1960,6 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
logger.error(ex, exc_info=True)
raise
@classmethod
def is_select_query(cls, parsed_query: ParsedQuery) -> bool:
"""
Determine if the statement should be considered as SELECT statement.
Some query dialects do not contain "SELECT" word in queries (eg. Kusto)
"""
return parsed_query.is_select()
@classmethod
def get_column_spec( # pylint: disable=unused-argument
cls,
@@ -2201,10 +2065,6 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
return False
@classmethod
def parse_sql(cls, sql: str) -> list[str]:
return [str(s).strip(" ;") for s in sqlparse.parse(sql)]
@classmethod
def get_impersonation_key(cls, user: User | None) -> Any:
"""

View File

@@ -20,9 +20,9 @@ from typing import Optional, Union
from sqlalchemy.engine.reflection import Inspector
from superset.constants import TimeGrain
from superset.db_engine_specs.base import BaseEngineSpec, LimitMethod
from superset.db_engine_specs.base import BaseEngineSpec
from superset.models.core import Database
from superset.sql_parse import Table
from superset.sql.parse import LimitMethod, Table
logger = logging.getLogger(__name__)

View File

@@ -20,7 +20,8 @@ from typing import Any, Optional
from sqlalchemy import types
from superset.constants import TimeGrain
from superset.db_engine_specs.base import BaseEngineSpec, LimitMethod
from superset.db_engine_specs.base import BaseEngineSpec
from superset.sql.parse import LimitMethod
class FirebirdEngineSpec(BaseEngineSpec):

View File

@@ -20,8 +20,8 @@ from typing import Any, Optional
from sqlalchemy import types
from superset.constants import TimeGrain
from superset.db_engine_specs.base import LimitMethod
from superset.db_engine_specs.postgres import PostgresBaseEngineSpec
from superset.sql.parse import LimitMethod
class HanaEngineSpec(PostgresBaseEngineSpec):

View File

@@ -22,13 +22,13 @@ from sqlalchemy import types
from sqlalchemy.dialects.mssql.base import SMALLDATETIME
from superset.constants import TimeGrain
from superset.db_engine_specs.base import BaseEngineSpec, LimitMethod
from superset.db_engine_specs.base import BaseEngineSpec
from superset.db_engine_specs.exceptions import (
SupersetDBAPIDatabaseError,
SupersetDBAPIOperationalError,
SupersetDBAPIProgrammingError,
)
from superset.sql_parse import ParsedQuery
from superset.sql.parse import LimitMethod
from superset.utils.core import GenericDataType
@@ -106,7 +106,6 @@ class KustoSqlEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method
class KustoKqlEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method
limit_method = LimitMethod.WRAP_SQL
engine = "kustokql"
engine_name = "KustoKQL"
time_groupby_inline = True
@@ -154,15 +153,3 @@ class KustoKqlEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method
return f"""datetime({dttm.isoformat(timespec="microseconds")})"""
return None
@classmethod
def is_select_query(cls, parsed_query: ParsedQuery) -> bool:
return not parsed_query.sql.startswith(".")
@classmethod
def parse_sql(cls, sql: str) -> list[str]:
"""
Kusto supports a single query statement, but it could include sub queries
and variables declared via let keyword.
"""
return [sql]

View File

@@ -119,7 +119,7 @@ def diagnose(spec: type[BaseEngineSpec]) -> dict[str, Any]:
output.update(
{
"module": spec.__module__,
"limit_method": spec.limit_method.upper(),
"limit_method": spec.limit_method.value,
"joins": spec.allows_joins,
"subqueries": spec.allows_subqueries,
"alias_in_select": spec.allows_alias_in_select,
@@ -129,7 +129,6 @@ def diagnose(spec: type[BaseEngineSpec]) -> dict[str, Any]:
"order_by_not_in_select": spec.allows_hidden_orderby_agg,
"expressions_in_orderby": spec.allows_hidden_cc_in_orderby,
"cte_in_subquery": spec.allows_cte_in_subquery,
"limit_clause": spec.allow_limit_clause,
"max_column_name": spec.max_column_name_length,
"sql_comments": spec.allows_sql_comments,
"escaped_colons": spec.allows_escaped_colons,
@@ -223,7 +222,7 @@ def generate_table() -> list[list[Any]]:
rows = [] # pylint: disable=redefined-outer-name
rows.append(["Feature"] + list(info)) # header row
rows.append(["Module"] + list(db_info["module"] for db_info in info.values())) # noqa: C400
rows.append(["Module"] + [db_info["module"] for db_info in info.values()])
# descriptive
keys = [
@@ -244,14 +243,14 @@ def generate_table() -> list[list[Any]]:
]
for key in keys:
rows.append(
[DATABASE_DETAILS[key]] + list(db_info[key] for db_info in info.values()) # noqa: C400
[DATABASE_DETAILS[key]] + [db_info[key] for db_info in info.values()]
)
# basic
for time_grain in TimeGrain:
rows.append(
[f"Has time grain {time_grain.name}"]
+ list(db_info["time_grains"][time_grain.name] for db_info in info.values()) # noqa: C400
+ [db_info["time_grains"][time_grain.name] for db_info in info.values()]
)
keys = [
"masked_encrypted_extra",
@@ -259,9 +258,7 @@ def generate_table() -> list[list[Any]]:
"function_names",
]
for key in keys:
rows.append(
[BASIC_FEATURES[key]] + list(db_info[key] for db_info in info.values()) # noqa: C400
)
rows.append([BASIC_FEATURES[key]] + [db_info[key] for db_info in info.values()])
# nice to have
keys = [
@@ -280,8 +277,7 @@ def generate_table() -> list[list[Any]]:
]
for key in keys:
rows.append(
[NICE_TO_HAVE_FEATURES[key]]
+ list(db_info[key] for db_info in info.values()) # noqa: C400
[NICE_TO_HAVE_FEATURES[key]] + [db_info[key] for db_info in info.values()]
)
# advanced
@@ -292,10 +288,10 @@ def generate_table() -> list[list[Any]]:
]
for key in keys:
rows.append(
[ADVANCED_FEATURES[key]] + list(db_info[key] for db_info in info.values()) # noqa: C400
[ADVANCED_FEATURES[key]] + [db_info[key] for db_info in info.values()]
)
rows.append(["Score"] + list(db_info["score"] for db_info in info.values())) # noqa: C400
rows.append(["Score"] + [db_info["score"] for db_info in info.values()])
return rows

View File

@@ -27,7 +27,7 @@ from sqlalchemy import types
from sqlalchemy.dialects.mssql.base import SMALLDATETIME
from superset.constants import TimeGrain
from superset.db_engine_specs.base import BaseEngineSpec, LimitMethod
from superset.db_engine_specs.base import BaseEngineSpec
from superset.errors import SupersetErrorType
from superset.models.sql_types.mssql_sql_types import GUID
from superset.utils.core import GenericDataType
@@ -52,10 +52,8 @@ CONNECTION_HOST_DOWN_REGEX = re.compile(
class MssqlEngineSpec(BaseEngineSpec):
engine = "mssql"
engine_name = "Microsoft SQL Server"
limit_method = LimitMethod.WRAP_SQL
max_column_name_length = 128
allows_cte_in_subquery = False
allow_limit_clause = False
supports_multivalues_insert = True
_time_grain_expressions = {

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