mirror of
https://github.com/apache/superset.git
synced 2026-06-14 03:59:22 +00:00
Compare commits
33 Commits
fix_exampl
...
loader-exp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8013b32f0e | ||
|
|
adeed60fe0 | ||
|
|
546945e7a6 | ||
|
|
5b2f1bbf9e | ||
|
|
875f538d54 | ||
|
|
b7d3ff1e85 | ||
|
|
c03964dc5f | ||
|
|
950a3313d8 | ||
|
|
e2a22d481c | ||
|
|
b4e2406385 | ||
|
|
ca9e74edd8 | ||
|
|
39b3de6b5d | ||
|
|
26563bb330 | ||
|
|
0653e123cc | ||
|
|
76358ed64e | ||
|
|
217f11a8f7 | ||
|
|
af21ef2497 | ||
|
|
51c25831e8 | ||
|
|
be41e0526a | ||
|
|
0f240ea1b2 | ||
|
|
e520538af6 | ||
|
|
e03d840d06 | ||
|
|
1921ba993e | ||
|
|
b050897ebd | ||
|
|
0bdd8a223d | ||
|
|
d12f86363f | ||
|
|
9f680a63f8 | ||
|
|
928a052440 | ||
|
|
fbc84a1f9a | ||
|
|
fa1693dc5f | ||
|
|
8a8fb49617 | ||
|
|
dc4474889d | ||
|
|
29ac507d56 |
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -27,6 +27,8 @@ updates:
|
||||
- package-ecosystem: "uv"
|
||||
directory: "requirements/"
|
||||
open-pull-requests-limit: 10
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
- uv
|
||||
- dependabot
|
||||
|
||||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -64,6 +64,51 @@ check the [supersetbot docker](https://github.com/apache-superset/supersetbot)
|
||||
subcommand and the [docker.yml](https://github.com/apache/superset/blob/master/.github/workflows/docker.yml)
|
||||
GitHub action.
|
||||
|
||||
## Building your own production Docker image
|
||||
|
||||
Every Superset deployment will require its own set of drivers depending on the data warehouse(s),
|
||||
etc. so we recommend that users build their own Docker image by extending the `lean` image.
|
||||
|
||||
Here's an example Dockerfile that does this. Follow the in-line comments to customize it for
|
||||
your desired Superset version and database drivers. The comments also note that a certain feature flag will
|
||||
have to be enabled in your config file.
|
||||
|
||||
You would build the image with `docker build -t mysuperset:latest .` or `docker build -t ourcompanysuperset:4.1.2 .`
|
||||
|
||||
```Dockerfile
|
||||
# change this to apache/superset:4.1.2 or whatever version you want to build from;
|
||||
# otherwise the default is the latest commit on GitHub master branch
|
||||
FROM apache/superset:master
|
||||
|
||||
USER root
|
||||
|
||||
# Set environment variable for Playwright
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/playwright-browsers
|
||||
|
||||
# Install packages using uv into the virtual environment
|
||||
# Superset started using uv after the 4.1 branch; if you are building from apache/superset:4.1.x,
|
||||
# replace the first two lines with RUN pip install \
|
||||
RUN . /app/.venv/bin/activate && \
|
||||
uv pip install \
|
||||
# install psycopg2 for using PostgreSQL metadata store - could be a MySQL package if using that backend:
|
||||
psycopg2-binary \
|
||||
# add the driver(s) for your data warehouse(s), in this example we're showing for Microsoft SQL Server:
|
||||
pymssql \
|
||||
# package needed for using single-sign on authentication:
|
||||
Authlib \
|
||||
# install Playwright for taking screenshots for Alerts & Reports. This assumes the feature flag PLAYWRIGHT_REPORTS_AND_THUMBNAILS is enabled
|
||||
# That feature flag will default to True starting in 6.0.0
|
||||
# Playwright works only with Chrome.
|
||||
# If you are still using Selenium instead of Playwright, you would instead install here the selenium package and a headless browser & webdriver
|
||||
playwright \
|
||||
&& PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/playwright-browsers playwright install chromium
|
||||
|
||||
# Switch back to the superset user
|
||||
USER superset
|
||||
|
||||
CMD ["/app/docker/entrypoints/run-server.sh"]
|
||||
```
|
||||
|
||||
## Key ARGs in Dockerfile
|
||||
|
||||
- `BUILD_TRANSLATIONS`: whether to build the translations into the image. For the
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@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",
|
||||
@@ -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": [
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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.6.4, <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",
|
||||
]
|
||||
|
||||
@@ -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.6.4
|
||||
# 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
|
||||
|
||||
@@ -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.6.4
|
||||
# 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
|
||||
|
||||
850
superset-frontend/package-lock.json
generated
850
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function getLayer(
|
||||
});
|
||||
}
|
||||
|
||||
function getPoints(data: JsonObject[]) {
|
||||
export function getPoints(data: JsonObject[]) {
|
||||
return data.map(d => d.position);
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export const getLayer: getLayerType<unknown> = (
|
||||
});
|
||||
};
|
||||
|
||||
function getPoints(data: any[]) {
|
||||
export function getPoints(data: any[]) {
|
||||
return data.map(d => d.position);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export function getLayer(
|
||||
});
|
||||
}
|
||||
|
||||
function getPoints(data: JsonObject[]) {
|
||||
export function getPoints(data: JsonObject[]) {
|
||||
return data.map(d => d.position);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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)],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
@@ -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],
|
||||
[
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -74,6 +74,7 @@ export type EchartsTimeseriesFormData = QueryFormData & {
|
||||
rowLimit: number;
|
||||
seriesType: EchartsTimeseriesSeriesType;
|
||||
stack: StackType;
|
||||
stackDimension: string;
|
||||
timeCompare?: string[];
|
||||
tooltipTimeFormat?: string;
|
||||
showTooltipTotal?: boolean;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}, [
|
||||
|
||||
@@ -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>
|
||||
|
||||
1276
superset-websocket/utils/client-ws-app/package-lock.json
generated
1276
superset-websocket/utils/client-ws-app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -65,7 +65,6 @@ 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.superset_typing import (
|
||||
OAuth2ClientConfig,
|
||||
OAuth2State,
|
||||
@@ -1221,8 +1220,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
|
||||
script = SQLScript(sql, engine=cls.engine)
|
||||
return script.statements[-1].get_limit_value()
|
||||
|
||||
@classmethod
|
||||
def set_or_update_query_limit(cls, sql: str, limit: int) -> str:
|
||||
@@ -2088,14 +2087,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,
|
||||
|
||||
@@ -28,7 +28,6 @@ from superset.db_engine_specs.exceptions import (
|
||||
SupersetDBAPIOperationalError,
|
||||
SupersetDBAPIProgrammingError,
|
||||
)
|
||||
from superset.sql_parse import ParsedQuery
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
|
||||
@@ -155,10 +154,6 @@ class KustoKqlEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method
|
||||
|
||||
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]:
|
||||
"""
|
||||
|
||||
@@ -47,9 +47,9 @@ def load_flights(only_metadata: bool = False, force: bool = False) -> None:
|
||||
)
|
||||
airports = airports.set_index("IATA_CODE")
|
||||
|
||||
pdf[ # pylint: disable=unsupported-assignment-operation,useless-suppression
|
||||
"ds"
|
||||
] = pdf.YEAR.map(str) + "-0" + pdf.MONTH.map(str) + "-0" + pdf.DAY.map(str)
|
||||
pdf["ds"] = (
|
||||
pdf.YEAR.map(str) + "-0" + pdf.MONTH.map(str) + "-0" + pdf.DAY.map(str)
|
||||
)
|
||||
pdf.ds = pd.to_datetime(pdf.ds)
|
||||
pdf.drop(columns=["DAY", "MONTH", "YEAR"])
|
||||
pdf = pdf.join(airports, on="ORIGIN_AIRPORT", rsuffix="_ORIG")
|
||||
|
||||
@@ -506,9 +506,21 @@ def cast_text_column_to_json(
|
||||
conn.execute(
|
||||
text(
|
||||
f"""
|
||||
ALTER TABLE {table}
|
||||
ALTER COLUMN {column} TYPE jsonb
|
||||
USING {column}::jsonb
|
||||
CREATE OR REPLACE FUNCTION safe_to_jsonb(input text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN input::jsonb;
|
||||
EXCEPTION WHEN invalid_text_representation THEN
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER TABLE {table}
|
||||
ALTER COLUMN {column} TYPE jsonb
|
||||
USING safe_to_jsonb({column});
|
||||
"""
|
||||
)
|
||||
)
|
||||
@@ -525,6 +537,13 @@ def cast_text_column_to_json(
|
||||
stmt_select = select(t.c[pk], t.c[column]).where(t.c[column].is_not(None))
|
||||
|
||||
for row_pk, value in conn.execute(stmt_select):
|
||||
try:
|
||||
json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
f"Invalid JSON value in column {column} for {pk}={row_pk}: {value}"
|
||||
)
|
||||
continue
|
||||
stmt_update = update(t).where(t.c[pk] == row_pk).values({tmp_column: value})
|
||||
conn.execute(stmt_update)
|
||||
|
||||
|
||||
@@ -307,7 +307,6 @@ class Database(Model, AuditMixinNullable, ImportExportMixin): # pylint: disable
|
||||
with suppress(TypeError, json.JSONDecodeError):
|
||||
encrypted_config = json.loads(masked_encrypted_extra)
|
||||
try:
|
||||
# pylint: disable=useless-suppression
|
||||
parameters = self.db_engine_spec.get_parameters_from_uri( # type: ignore
|
||||
masked_uri,
|
||||
encrypted_extra=encrypted_config,
|
||||
|
||||
@@ -32,7 +32,7 @@ from deprecation import deprecated
|
||||
from sqlglot import exp
|
||||
from sqlglot.dialects.dialect import Dialect, Dialects
|
||||
from sqlglot.errors import ParseError
|
||||
from sqlglot.expressions import Func
|
||||
from sqlglot.expressions import Func, Limit
|
||||
from sqlglot.optimizer.pushdown_predicates import pushdown_predicates
|
||||
from sqlglot.optimizer.scope import Scope, ScopeType, traverse_scope
|
||||
|
||||
@@ -237,6 +237,21 @@ class BaseSQLStatement(Generic[InternalRepresentation]):
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def check_functions_present(self, functions: set[str]) -> bool:
|
||||
"""
|
||||
Check if any of the given functions are present in the script.
|
||||
|
||||
:param functions: List of functions to check for
|
||||
:return: True if any of the functions are present
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_limit_value(self) -> int | None:
|
||||
"""
|
||||
Get the limit value of the statement.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.format()
|
||||
|
||||
@@ -471,6 +486,24 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
}
|
||||
return any(function.upper() in present for function in functions)
|
||||
|
||||
def get_limit_value(self) -> int | None:
|
||||
"""
|
||||
Parse a SQL query and return the `LIMIT` or `TOP` value, if present.
|
||||
"""
|
||||
limit_node = (
|
||||
self._parsed
|
||||
if isinstance(self._parsed, Limit)
|
||||
else self._parsed.args.get("limit")
|
||||
)
|
||||
if not isinstance(limit_node, exp.Limit):
|
||||
return None
|
||||
|
||||
literal = limit_node.args.get("expression") or getattr(limit_node, "this", None)
|
||||
if isinstance(literal, exp.Literal) and literal.is_int:
|
||||
return int(literal.name)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class KQLSplitState(enum.Enum):
|
||||
"""
|
||||
@@ -486,48 +519,118 @@ class KQLSplitState(enum.Enum):
|
||||
INSIDE_MULTILINE_STRING = enum.auto()
|
||||
|
||||
|
||||
class KQLTokenType(enum.Enum):
|
||||
"""
|
||||
Token types for KQL.
|
||||
"""
|
||||
|
||||
STRING = enum.auto()
|
||||
WORD = enum.auto()
|
||||
NUMBER = enum.auto()
|
||||
SEMICOLON = enum.auto()
|
||||
WHITESPACE = enum.auto()
|
||||
OTHER = enum.auto()
|
||||
|
||||
|
||||
def classify_non_string_kql(text: str) -> list[tuple[KQLTokenType, str]]:
|
||||
"""
|
||||
Classify non-string KQL.
|
||||
"""
|
||||
tokens: list[tuple[KQLTokenType, str]] = []
|
||||
for m in re.finditer(r"[A-Za-z_][A-Za-z_0-9]*|\d+|\s+|.", text):
|
||||
tok = m.group(0)
|
||||
if tok == ";":
|
||||
tokens.append((KQLTokenType.SEMICOLON, tok))
|
||||
elif tok.isdigit():
|
||||
tokens.append((KQLTokenType.NUMBER, tok))
|
||||
elif re.match(r"[A-Za-z_][A-Za-z_0-9]*", tok):
|
||||
tokens.append((KQLTokenType.WORD, tok))
|
||||
elif re.match(r"\s+", tok):
|
||||
tokens.append((KQLTokenType.WHITESPACE, tok))
|
||||
else:
|
||||
tokens.append((KQLTokenType.OTHER, tok))
|
||||
|
||||
return tokens
|
||||
|
||||
|
||||
def tokenize_kql(kql: str) -> list[tuple[KQLTokenType, str]]:
|
||||
"""
|
||||
Turn a KQL script into a flat list of tokens.
|
||||
"""
|
||||
|
||||
state = KQLSplitState.OUTSIDE_STRING
|
||||
tokens: list[tuple[KQLTokenType, str]] = []
|
||||
buffer = ""
|
||||
script = kql if kql.endswith(";") else kql + ";"
|
||||
|
||||
for i, ch in enumerate(script):
|
||||
if state == KQLSplitState.OUTSIDE_STRING:
|
||||
if ch in {"'", '"'}:
|
||||
if buffer:
|
||||
tokens.extend(classify_non_string_kql(buffer))
|
||||
buffer = ""
|
||||
state = (
|
||||
KQLSplitState.INSIDE_SINGLE_QUOTED_STRING
|
||||
if ch == "'"
|
||||
else KQLSplitState.INSIDE_DOUBLE_QUOTED_STRING
|
||||
)
|
||||
buffer = ch
|
||||
elif ch == "`" and script[i - 2 : i] == "``":
|
||||
if buffer:
|
||||
tokens.extend(classify_non_string_kql(buffer))
|
||||
buffer = ""
|
||||
state = KQLSplitState.INSIDE_MULTILINE_STRING
|
||||
buffer = "`"
|
||||
else:
|
||||
buffer += ch
|
||||
else:
|
||||
buffer += ch
|
||||
end_str = (
|
||||
(
|
||||
state == KQLSplitState.INSIDE_SINGLE_QUOTED_STRING
|
||||
and ch == "'"
|
||||
and script[i - 1] != "\\"
|
||||
)
|
||||
or (
|
||||
state == KQLSplitState.INSIDE_DOUBLE_QUOTED_STRING
|
||||
and ch == '"'
|
||||
and script[i - 1] != "\\"
|
||||
)
|
||||
or (
|
||||
state == KQLSplitState.INSIDE_MULTILINE_STRING
|
||||
and ch == "`"
|
||||
and script[i - 2 : i] == "``"
|
||||
)
|
||||
)
|
||||
if end_str:
|
||||
tokens.append((KQLTokenType.STRING, buffer))
|
||||
buffer = ""
|
||||
state = KQLSplitState.OUTSIDE_STRING
|
||||
|
||||
if buffer:
|
||||
tokens.extend(classify_non_string_kql(buffer))
|
||||
|
||||
return tokens
|
||||
|
||||
|
||||
def split_kql(kql: str) -> list[str]:
|
||||
"""
|
||||
Custom function for splitting KQL statements.
|
||||
Split a KQL script into statements on semicolons,
|
||||
ignoring those inside strings.
|
||||
"""
|
||||
statements = []
|
||||
state = KQLSplitState.OUTSIDE_STRING
|
||||
statement_start = 0
|
||||
script = kql if kql.endswith(";") else kql + ";"
|
||||
for i, character in enumerate(script):
|
||||
if state == KQLSplitState.OUTSIDE_STRING:
|
||||
if character == ";":
|
||||
statements.append(script[statement_start:i])
|
||||
statement_start = i + 1
|
||||
elif character == "'":
|
||||
state = KQLSplitState.INSIDE_SINGLE_QUOTED_STRING
|
||||
elif character == '"':
|
||||
state = KQLSplitState.INSIDE_DOUBLE_QUOTED_STRING
|
||||
elif character == "`" and script[i - 2 : i] == "``":
|
||||
state = KQLSplitState.INSIDE_MULTILINE_STRING
|
||||
tokens = tokenize_kql(kql)
|
||||
stmts_tokens: list[list[tuple[KQLTokenType, str]]] = []
|
||||
current: list[tuple[KQLTokenType, str]] = []
|
||||
|
||||
elif (
|
||||
state == KQLSplitState.INSIDE_SINGLE_QUOTED_STRING
|
||||
and character == "'"
|
||||
and script[i - 1] != "\\"
|
||||
):
|
||||
state = KQLSplitState.OUTSIDE_STRING
|
||||
for ttype, val in tokens:
|
||||
if ttype == KQLTokenType.SEMICOLON:
|
||||
if current:
|
||||
stmts_tokens.append(current)
|
||||
current = []
|
||||
else:
|
||||
current.append((ttype, val))
|
||||
|
||||
elif (
|
||||
state == KQLSplitState.INSIDE_DOUBLE_QUOTED_STRING
|
||||
and character == '"'
|
||||
and script[i - 1] != "\\"
|
||||
):
|
||||
state = KQLSplitState.OUTSIDE_STRING
|
||||
|
||||
elif (
|
||||
state == KQLSplitState.INSIDE_MULTILINE_STRING
|
||||
and character == "`"
|
||||
and script[i - 2 : i] == "``"
|
||||
):
|
||||
state = KQLSplitState.OUTSIDE_STRING
|
||||
|
||||
return statements
|
||||
return ["".join(val for _, val in stmt) for stmt in stmts_tokens]
|
||||
|
||||
|
||||
class KustoKQLStatement(BaseSQLStatement[str]):
|
||||
@@ -647,6 +750,23 @@ class KustoKQLStatement(BaseSQLStatement[str]):
|
||||
logger.warning("Kusto KQL doesn't support checking for functions present.")
|
||||
return True
|
||||
|
||||
def get_limit_value(self) -> int | None:
|
||||
"""
|
||||
Get the limit value of the statement.
|
||||
"""
|
||||
tokens = [
|
||||
token
|
||||
for token in tokenize_kql(self._sql)
|
||||
if token[0] != KQLTokenType.WHITESPACE
|
||||
]
|
||||
for idx, (ttype, val) in enumerate(tokens):
|
||||
if ttype != KQLTokenType.STRING and val.lower() in {"take", "limit"}:
|
||||
if idx + 1 < len(tokens) and tokens[idx + 1][0] == KQLTokenType.NUMBER:
|
||||
return int(tokens[idx + 1][1])
|
||||
break
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class SQLScript:
|
||||
"""
|
||||
|
||||
@@ -53,7 +53,7 @@ from superset.extensions import celery_app, event_logger
|
||||
from superset.models.core import Database
|
||||
from superset.models.sql_lab import Query
|
||||
from superset.result_set import SupersetResultSet
|
||||
from superset.sql.parse import SQLStatement, Table
|
||||
from superset.sql.parse import SQLScript, SQLStatement, Table
|
||||
from superset.sql_parse import (
|
||||
CtasMethod,
|
||||
insert_rls_as_subquery,
|
||||
@@ -263,6 +263,7 @@ def execute_sql_statement( # pylint: disable=too-many-statements, too-many-loca
|
||||
)
|
||||
raise SupersetErrorsException(errors)
|
||||
|
||||
original_sql = sql
|
||||
if apply_ctas:
|
||||
if not query.tmp_table_name:
|
||||
start_dttm = datetime.fromtimestamp(query.start_time)
|
||||
@@ -277,7 +278,7 @@ def execute_sql_statement( # pylint: disable=too-many-statements, too-many-loca
|
||||
query.select_as_cta_used = True
|
||||
|
||||
# Do not apply limit to the CTA queries when SQLLAB_CTAS_NO_LIMIT is set to true
|
||||
if db_engine_spec.is_select_query(parsed_query) and not (
|
||||
if not SQLScript(original_sql, db_engine_spec.engine).has_mutation() and not (
|
||||
query.select_as_cta_used and SQLLAB_CTAS_NO_LIMIT
|
||||
):
|
||||
if SQL_MAX_ROW and (not query.limit or query.limit > SQL_MAX_ROW):
|
||||
@@ -553,7 +554,7 @@ def execute_sql_statements( # noqa: C901
|
||||
|
||||
# Commit the connection so CTA queries will create the table and any DML.
|
||||
should_commit = (
|
||||
not db_engine_spec.is_select_query(parsed_query) # check if query is DML
|
||||
SQLScript(rendered_query, db_engine_spec.engine).has_mutation()
|
||||
or apply_ctas
|
||||
)
|
||||
if should_commit:
|
||||
|
||||
@@ -77,7 +77,7 @@ def parse_human_datetime(human_readable: str) -> datetime:
|
||||
|
||||
|
||||
def normalize_time_delta(human_readable: str) -> dict[str, int]:
|
||||
x_unit = r"^\s*([0-9]+)\s+(second|minute|hour|day|week|month|quarter|year)s?\s+(ago|later)*$" # pylint: disable=line-too-long,useless-suppression # noqa: E501
|
||||
x_unit = r"^\s*([0-9]+)\s+(second|minute|hour|day|week|month|quarter|year)s?\s+(ago|later)*$" # noqa: E501
|
||||
matched = re.match(x_unit, human_readable, re.IGNORECASE)
|
||||
if not matched:
|
||||
raise TimeDeltaAmbiguousError(human_readable)
|
||||
@@ -362,13 +362,13 @@ def get_since_until( # pylint: disable=too-many-arguments,too-many-locals,too-m
|
||||
and time_range.startswith("previous calendar week")
|
||||
and separator not in time_range
|
||||
):
|
||||
time_range = "DATETRUNC(DATEADD(DATETIME('today'), -1, WEEK), WEEK) : DATETRUNC(DATETIME('today'), WEEK)" # pylint: disable=line-too-long,useless-suppression # noqa: E501
|
||||
time_range = "DATETRUNC(DATEADD(DATETIME('today'), -1, WEEK), WEEK) : DATETRUNC(DATETIME('today'), WEEK)" # noqa: E501
|
||||
if (
|
||||
time_range
|
||||
and time_range.startswith("previous calendar month")
|
||||
and separator not in time_range
|
||||
):
|
||||
time_range = "DATETRUNC(DATEADD(DATETIME('today'), -1, MONTH), MONTH) : DATETRUNC(DATETIME('today'), MONTH)" # pylint: disable=line-too-long,useless-suppression # noqa: E501
|
||||
time_range = "DATETRUNC(DATEADD(DATETIME('today'), -1, MONTH), MONTH) : DATETRUNC(DATETIME('today'), MONTH)" # noqa: E501
|
||||
if (
|
||||
time_range
|
||||
and time_range.startswith("previous calendar quarter")
|
||||
@@ -376,44 +376,44 @@ def get_since_until( # pylint: disable=too-many-arguments,too-many-locals,too-m
|
||||
):
|
||||
time_range = (
|
||||
"DATETRUNC(DATEADD(DATETIME('today'), -1, QUARTER), QUARTER) : "
|
||||
"DATETRUNC(DATETIME('today'), QUARTER)" # pylint: disable=line-too-long,useless-suppression # noqa: E501
|
||||
"DATETRUNC(DATETIME('today'), QUARTER)" # noqa: E501
|
||||
)
|
||||
if (
|
||||
time_range
|
||||
and time_range.startswith("previous calendar year")
|
||||
and separator not in time_range
|
||||
):
|
||||
time_range = "DATETRUNC(DATEADD(DATETIME('today'), -1, YEAR), YEAR) : DATETRUNC(DATETIME('today'), YEAR)" # pylint: disable=line-too-long,useless-suppression # noqa: E501
|
||||
time_range = "DATETRUNC(DATEADD(DATETIME('today'), -1, YEAR), YEAR) : DATETRUNC(DATETIME('today'), YEAR)" # noqa: E501
|
||||
if (
|
||||
time_range
|
||||
and time_range.startswith("Current day")
|
||||
and separator not in time_range
|
||||
):
|
||||
time_range = "DATETRUNC(DATEADD(DATETIME('today'), 0, DAY), DAY) : DATETRUNC(DATEADD(DATETIME('today'), 1, DAY), DAY)" # pylint: disable=line-too-long,useless-suppression # noqa: E501
|
||||
time_range = "DATETRUNC(DATEADD(DATETIME('today'), 0, DAY), DAY) : DATETRUNC(DATEADD(DATETIME('today'), 1, DAY), DAY)" # noqa: E501
|
||||
if (
|
||||
time_range
|
||||
and time_range.startswith("Current week")
|
||||
and separator not in time_range
|
||||
):
|
||||
time_range = "DATETRUNC(DATEADD(DATETIME('today'), 0, WEEK), WEEK) : DATETRUNC(DATEADD(DATETIME('today'), 1, WEEK), WEEK)" # pylint: disable=line-too-long,useless-suppression # noqa: E501
|
||||
time_range = "DATETRUNC(DATEADD(DATETIME('today'), 0, WEEK), WEEK) : DATETRUNC(DATEADD(DATETIME('today'), 1, WEEK), WEEK)" # noqa: E501
|
||||
if (
|
||||
time_range
|
||||
and time_range.startswith("Current month")
|
||||
and separator not in time_range
|
||||
):
|
||||
time_range = "DATETRUNC(DATEADD(DATETIME('today'), 0, MONTH), MONTH) : DATETRUNC(DATEADD(DATETIME('today'), 1, MONTH), MONTH)" # pylint: disable=line-too-long,useless-suppression # noqa: E501
|
||||
time_range = "DATETRUNC(DATEADD(DATETIME('today'), 0, MONTH), MONTH) : DATETRUNC(DATEADD(DATETIME('today'), 1, MONTH), MONTH)" # noqa: E501
|
||||
if (
|
||||
time_range
|
||||
and time_range.startswith("Current quarter")
|
||||
and separator not in time_range
|
||||
):
|
||||
time_range = "DATETRUNC(DATEADD(DATETIME('today'), 0, QUARTER), QUARTER) : DATETRUNC(DATEADD(DATETIME('today'), 1, QUARTER), QUARTER)" # pylint: disable=line-too-long,useless-suppression # noqa: E501
|
||||
time_range = "DATETRUNC(DATEADD(DATETIME('today'), 0, QUARTER), QUARTER) : DATETRUNC(DATEADD(DATETIME('today'), 1, QUARTER), QUARTER)" # noqa: E501
|
||||
if (
|
||||
time_range
|
||||
and time_range.startswith("Current year")
|
||||
and separator not in time_range
|
||||
):
|
||||
time_range = "DATETRUNC(DATEADD(DATETIME('today'), 0, YEAR), YEAR) : DATETRUNC(DATEADD(DATETIME('today'), 1, YEAR), YEAR)" # pylint: disable=line-too-long,useless-suppression # noqa: E501
|
||||
time_range = "DATETRUNC(DATEADD(DATETIME('today'), 0, YEAR), YEAR) : DATETRUNC(DATEADD(DATETIME('today'), 1, YEAR), YEAR)" # noqa: E501
|
||||
|
||||
if time_range and separator in time_range:
|
||||
time_range_lookup = [
|
||||
@@ -421,7 +421,7 @@ def get_since_until( # pylint: disable=too-many-arguments,too-many-locals,too-m
|
||||
r"^(start of|beginning of|end of)\s+"
|
||||
r"(this|last|next|prior)\s+"
|
||||
r"([0-9]+)?\s*"
|
||||
r"(day|week|month|quarter|year)s?$", # Matches phrases like "start of next month" # pylint: disable=line-too-long,useless-suppression # noqa: E501
|
||||
r"(day|week|month|quarter|year)s?$", # Matches phrases like "start of next month" # noqa: E501
|
||||
lambda modifier, scope, delta, unit: handle_modifier_and_unit(
|
||||
modifier,
|
||||
scope,
|
||||
@@ -433,13 +433,13 @@ def get_since_until( # pylint: disable=too-many-arguments,too-many-locals,too-m
|
||||
(
|
||||
r"^(this|last|next|prior)\s+"
|
||||
r"([0-9]+)?\s*"
|
||||
r"(second|minute|day|week|month|quarter|year)s?$", # Matches "next 5 days" or "last 2 weeks" # pylint: disable=line-too-long,useless-suppression # noqa: E501
|
||||
r"(second|minute|day|week|month|quarter|year)s?$", # Matches "next 5 days" or "last 2 weeks" # noqa: E501
|
||||
lambda scope, delta, unit: handle_scope_and_unit(
|
||||
scope, delta, unit, get_relative_base(unit, relative_start)
|
||||
),
|
||||
),
|
||||
(
|
||||
r"^(DATETIME.*|DATEADD.*|DATETRUNC.*|LASTDAY.*|HOLIDAY.*)$", # Matches date-related keywords # pylint: disable=line-too-long,useless-suppression # noqa: E501
|
||||
r"^(DATETIME.*|DATEADD.*|DATETRUNC.*|LASTDAY.*|HOLIDAY.*)$", # Matches date-related keywords # noqa: E501
|
||||
lambda text: text,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -22,6 +22,7 @@ from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
from flask_appbuilder.security.decorators import has_access
|
||||
|
||||
from superset import is_feature_enabled
|
||||
from superset.constants import RouteMethod
|
||||
from superset.superset_typing import FlaskResponse
|
||||
from superset.tags.models import Tag
|
||||
from superset.views.base import SupersetModelView
|
||||
@@ -33,7 +34,7 @@ class TaggedObjectsModelView(SupersetModelView):
|
||||
route_base = "/superset/all_entities"
|
||||
datamodel = SQLAInterface(Tag)
|
||||
class_permission_name = "Tags"
|
||||
include_route_methods = {"list"}
|
||||
include_route_methods = {RouteMethod.LIST}
|
||||
|
||||
@has_access
|
||||
@expose("/")
|
||||
|
||||
@@ -1532,7 +1532,31 @@ class DeckGLMultiLayer(BaseViz):
|
||||
|
||||
slice_ids = self.form_data.get("deck_slices")
|
||||
slices = db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all()
|
||||
|
||||
features: dict[str, list[Any]] = {}
|
||||
|
||||
for slc in slices:
|
||||
form_data = slc.form_data
|
||||
|
||||
form_data["extra_filters"] = self.form_data.get("extra_filters", [])
|
||||
form_data["extra_form_data"] = self.form_data.get("extra_form_data", {})
|
||||
form_data["adhoc_filters"] = self.form_data.get("adhoc_filters")
|
||||
|
||||
viz_type_name = form_data.get("viz_type")
|
||||
viz_class = viz_types.get(viz_type_name)
|
||||
if not viz_class:
|
||||
continue # skip unknown viz types
|
||||
|
||||
viz_instance = viz_class(datasource=slc.datasource, form_data=form_data)
|
||||
payload = viz_instance.get_payload()
|
||||
|
||||
if payload and "data" in payload and "features" in payload["data"]:
|
||||
if viz_type_name not in features:
|
||||
features[viz_type_name] = []
|
||||
features[viz_type_name].extend(payload["data"]["features"])
|
||||
|
||||
return {
|
||||
"features": features,
|
||||
"mapboxApiKey": config["MAPBOX_API_KEY"],
|
||||
"slices": [slc.data for slc in slices],
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ from sqlalchemy import column
|
||||
|
||||
from superset.db_engine_specs.kusto import KustoKqlEngineSpec
|
||||
from superset.sql.parse import SQLScript
|
||||
from superset.sql_parse import ParsedQuery
|
||||
from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm
|
||||
from tests.unit_tests.fixtures.common import dttm # noqa: F401
|
||||
|
||||
@@ -53,26 +52,6 @@ def test_sql_has_mutation(sql: str, expected: bool) -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kql,expected",
|
||||
[
|
||||
("tbl | limit 100", True),
|
||||
("let foo = 1; tbl | where bar == foo", True),
|
||||
(".show tables", False),
|
||||
],
|
||||
)
|
||||
def test_kql_is_select_query(kql: str, expected: bool) -> None:
|
||||
"""
|
||||
Make sure that KQL dialect consider only statements that do not start with "." (dot)
|
||||
as a SELECT statements
|
||||
"""
|
||||
|
||||
from superset.db_engine_specs.kusto import KustoKqlEngineSpec
|
||||
|
||||
parsed_query = ParsedQuery(kql)
|
||||
assert KustoKqlEngineSpec.is_select_query(parsed_query) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kql,expected",
|
||||
[
|
||||
|
||||
@@ -1185,3 +1185,63 @@ def test_firebolt_old_escape_string() -> None:
|
||||
'foo''bar',
|
||||
'foo''bar'"""
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql, engine, expected",
|
||||
[
|
||||
("SELECT * FROM users LIMIT 10", "postgresql", 10),
|
||||
("SELECT * FROM users ORDER BY id DESC LIMIT 25", "postgresql", 25),
|
||||
("SELECT * FROM users", "postgresql", None),
|
||||
("SELECT TOP 5 name FROM employees", "teradatasql", 5),
|
||||
("SELECT TOP (42) * FROM table_name", "teradatasql", 42),
|
||||
("select * from table", "postgresql", None),
|
||||
("select * from mytable limit 10", "postgresql", 10),
|
||||
(
|
||||
"select * from (select * from my_subquery limit 10) where col=1 limit 20",
|
||||
"postgresql",
|
||||
20,
|
||||
),
|
||||
("select * from (select * from my_subquery limit 10);", "postgresql", None),
|
||||
(
|
||||
"select * from (select * from my_subquery limit 10) where col=1 limit 20;",
|
||||
"postgresql",
|
||||
20,
|
||||
),
|
||||
("select * from mytable limit 20, 10", "postgresql", 10),
|
||||
("select * from mytable limit 10 offset 20", "postgresql", 10),
|
||||
(
|
||||
"""
|
||||
SELECT id, value, i
|
||||
FROM (SELECT * FROM my_table LIMIT 10),
|
||||
LATERAL generate_series(1, value) AS i;
|
||||
""",
|
||||
"postgresql",
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_limit_value(sql, engine, expected):
|
||||
assert SQLStatement(sql, engine).get_limit_value() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kql, expected",
|
||||
[
|
||||
("StormEvents | take 10", 10),
|
||||
("StormEvents | limit 20", 20),
|
||||
("StormEvents | where State == 'FL' | summarize count()", None),
|
||||
("StormEvents | where name has 'limit 10'", None),
|
||||
("AnotherTable | take 5", 5),
|
||||
("datatable(x:int) [1, 2, 3] | take 100", 100),
|
||||
(
|
||||
"""
|
||||
Table1 | where msg contains 'abc;xyz'
|
||||
| limit 5
|
||||
""",
|
||||
5,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_kql_limit_value(kql, expected):
|
||||
assert KustoKQLStatement(kql, "kustokql").get_limit_value() == expected
|
||||
|
||||
@@ -52,7 +52,6 @@ def test_execute_sql_statement(mocker: MockerFixture, app: None) -> None:
|
||||
database.apply_limit_to_sql.return_value = "SELECT 42 AS answer LIMIT 2"
|
||||
database.mutate_sql_based_on_config.return_value = "SELECT 42 AS answer LIMIT 2"
|
||||
db_engine_spec = database.db_engine_spec
|
||||
db_engine_spec.is_select_query.return_value = True
|
||||
db_engine_spec.fetch_data.return_value = [(42,)]
|
||||
|
||||
cursor = mocker.MagicMock()
|
||||
@@ -95,7 +94,6 @@ def test_execute_sql_statement_with_rls(
|
||||
database.apply_limit_to_sql.return_value = sql_statement_with_rls_and_limit
|
||||
database.mutate_sql_based_on_config.return_value = sql_statement_with_rls_and_limit
|
||||
db_engine_spec = database.db_engine_spec
|
||||
db_engine_spec.is_select_query.return_value = True
|
||||
db_engine_spec.fetch_data.return_value = [(42,)]
|
||||
|
||||
cursor = mocker.MagicMock()
|
||||
@@ -140,7 +138,6 @@ def test_execute_sql_statement_exceeds_payload_limit(mocker: MockerFixture) -> N
|
||||
query = mocker.MagicMock()
|
||||
query.limit = 1
|
||||
query.database = mocker.MagicMock()
|
||||
query.database.db_engine_spec.is_select_query.return_value = True
|
||||
query.database.cache_timeout = 100
|
||||
query.status = "RUNNING"
|
||||
query.select_as_cta = False
|
||||
@@ -193,7 +190,6 @@ def test_execute_sql_statement_within_payload_limit(mocker: MockerFixture) -> No
|
||||
query = mocker.MagicMock()
|
||||
query.limit = 1
|
||||
query.database = mocker.MagicMock()
|
||||
query.database.db_engine_spec.is_select_query.return_value = True
|
||||
query.database.cache_timeout = 100
|
||||
query.status = "RUNNING"
|
||||
query.select_as_cta = False
|
||||
|
||||
Reference in New Issue
Block a user