mirror of
https://github.com/apache/superset.git
synced 2026-07-13 10:15:58 +00:00
Compare commits
62 Commits
loader-exp
...
fix-long-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fca4900cd9 | ||
|
|
ca74ae75a6 | ||
|
|
ae6c072661 | ||
|
|
5f2f12d347 | ||
|
|
fc7ba060c1 | ||
|
|
3a3984006c | ||
|
|
d11b6d557e | ||
|
|
2f007bf7a5 | ||
|
|
6513445000 | ||
|
|
3ef92e5610 | ||
|
|
57bb425fb0 | ||
|
|
2fba789e8d | ||
|
|
08655a7559 | ||
|
|
3256008a59 | ||
|
|
da8efd36d7 | ||
|
|
5541dad32b | ||
|
|
b3f436a030 | ||
|
|
b00660acf1 | ||
|
|
a6af4f4d7a | ||
|
|
cc3460832f | ||
|
|
edc60914f6 | ||
|
|
c9518485ba | ||
|
|
a26e1d822a | ||
|
|
a7aa8f7cef | ||
|
|
ff34e3c81e | ||
|
|
20519158d2 | ||
|
|
cacf1e06d6 | ||
|
|
fa0c5891bf | ||
|
|
fc13a0fde5 | ||
|
|
ade85daee2 | ||
|
|
2d26af25c1 | ||
|
|
b033406387 | ||
|
|
c09f8f6f76 | ||
|
|
401ce56fa1 | ||
|
|
cf315388f2 | ||
|
|
f219dc1794 | ||
|
|
ed20d2a917 | ||
|
|
235c9d2ebf | ||
|
|
fdea4e21b0 | ||
|
|
e20a08cb14 | ||
|
|
429935a277 | ||
|
|
a4bb11c755 | ||
|
|
f0b6e87091 | ||
|
|
ea5a609d0b | ||
|
|
0abe6eed89 | ||
|
|
e205846845 | ||
|
|
deef923825 | ||
|
|
0fa3feb088 | ||
|
|
1393f7d3d2 | ||
|
|
b7ba50033a | ||
|
|
ce9759785a | ||
|
|
8de58b9848 | ||
|
|
cc8ab2c556 | ||
|
|
1409b1a25b | ||
|
|
bdfb698aa4 | ||
|
|
57183da315 | ||
|
|
c928f23e1b | ||
|
|
0c89914a6d | ||
|
|
630e0e0240 | ||
|
|
513047c3bb | ||
|
|
d932837a3c | ||
|
|
38868f9ff4 |
36
.coveragerc
Normal file
36
.coveragerc
Normal file
@@ -0,0 +1,36 @@
|
||||
# .coveragerc to control coverage.py
|
||||
[run]
|
||||
branch = True
|
||||
source = superset
|
||||
# omit = bad_file.py
|
||||
|
||||
[paths]
|
||||
source =
|
||||
superset/
|
||||
*/site-packages/
|
||||
|
||||
[report]
|
||||
# Regexes for lines to exclude from consideration
|
||||
exclude_lines =
|
||||
# Have to re-enable the standard pragma
|
||||
pragma: no cover
|
||||
|
||||
# Don't complain about missing debug-only code:
|
||||
def __repr__
|
||||
if self\.debug
|
||||
|
||||
# Don't complain if tests don't hit defensive assertion code:
|
||||
raise AssertionError
|
||||
raise NotImplementedError
|
||||
|
||||
# Don't complain if non-runnable code isn't run:
|
||||
if 0:
|
||||
if __name__ == .__main__.:
|
||||
|
||||
# Ignore importlib backport
|
||||
from importlib
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
#fail_under = 100
|
||||
show_missing = True
|
||||
2
.github/workflows/pre-commit.yml
vendored
2
.github/workflows/pre-commit.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["current", "previous"]
|
||||
python-version: ["current", "previous", "next"]
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
2
.github/workflows/superset-docs-verify.yml
vendored
2
.github/workflows/superset-docs-verify.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Do not bump this linkinator-action version without opening
|
||||
# an ASF Infra ticket to allow the new verison first!
|
||||
# an ASF Infra ticket to allow the new version first!
|
||||
- uses: JustinBeckwith/linkinator-action@v1.11.0
|
||||
continue-on-error: true # This will make the job advisory (non-blocking, no red X)
|
||||
with:
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["current", "previous"]
|
||||
python-version: ["current", "previous", "next"]
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["previous", "current"]
|
||||
python-version: ["previous", "current", "next"]
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
steps:
|
||||
@@ -45,6 +45,13 @@ jobs:
|
||||
SUPERSET_SECRET_KEY: not-a-secret
|
||||
run: |
|
||||
pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear --maxfail=50
|
||||
- name: Python 100% coverage unit tests
|
||||
if: steps.check.outputs.python
|
||||
env:
|
||||
SUPERSET_TESTENV: true
|
||||
SUPERSET_SECRET_KEY: not-a-secret
|
||||
run: |
|
||||
pytest --durations-min=0.5 --cov-report= --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
######################################################################
|
||||
# Node stage to deal with static asset construction
|
||||
######################################################################
|
||||
ARG PY_VER=3.11.11-slim-bookworm
|
||||
ARG PY_VER=3.11.12-slim-bookworm
|
||||
|
||||
# If BUILDPLATFORM is null, set it to 'amd64' (or leave as is otherwise).
|
||||
ARG BUILDPLATFORM=${BUILDPLATFORM:-amd64}
|
||||
|
||||
@@ -103,7 +103,7 @@ Here are some of the major database solutions that are supported:
|
||||
|
||||
<p align="center">
|
||||
<img src="https://superset.apache.org/img/databases/redshift.png" alt="redshift" border="0" width="200"/>
|
||||
<img src="https://superset.apache.org/img/databases/google-biquery.png" alt="google-biquery" border="0" width="200"/>
|
||||
<img src="https://superset.apache.org/img/databases/google-biquery.png" alt="google-bigquery" border="0" width="200"/>
|
||||
<img src="https://superset.apache.org/img/databases/snowflake.png" alt="snowflake" border="0" width="200"/>
|
||||
<img src="https://superset.apache.org/img/databases/trino.png" alt="trino" border="0" width="150" />
|
||||
<img src="https://superset.apache.org/img/databases/presto.png" alt="presto" border="0" width="200"/>
|
||||
@@ -136,7 +136,7 @@ Here are some of the major database solutions that are supported:
|
||||
<img src="https://superset.apache.org/img/databases/starrocks.png" alt="starrocks" border="0" width="200" />
|
||||
<img src="https://superset.apache.org/img/databases/doris.png" alt="doris" border="0" width="200" />
|
||||
<img src="https://superset.apache.org/img/databases/oceanbase.svg" alt="oceanbase" border="0" width="220" />
|
||||
<img src="https://superset.apache.org/img/databases/sap-hana.png" alt="oceanbase" border="0" width="220" />
|
||||
<img src="https://superset.apache.org/img/databases/sap-hana.png" alt="sap-hana" border="0" width="220" />
|
||||
<img src="https://superset.apache.org/img/databases/denodo.png" alt="denodo" border="0" width="200" />
|
||||
<img src="https://superset.apache.org/img/databases/ydb.svg" alt="ydb" border="0" width="200" />
|
||||
<img src="https://superset.apache.org/img/databases/tdengine.png" alt="TDengine" border="0" width="200" />
|
||||
|
||||
@@ -105,6 +105,7 @@ Join our growing community!
|
||||
- [Formbricks](https://formbricks.com)
|
||||
- [Gavagai](https://gavagai.io) [@gavagai-corp]
|
||||
- [GfK Data Lab](https://www.gfk.com/home) [@mherr]
|
||||
- [HPE](https://www.hpe.com/in/en/home.html) [@anmol-hpe]
|
||||
- [Hydrolix](https://www.hydrolix.io/)
|
||||
- [Intercom](https://www.intercom.com/) [@kate-gallo]
|
||||
- [jampp](https://jampp.com/)
|
||||
|
||||
@@ -65,8 +65,6 @@ services:
|
||||
superset-init:
|
||||
condition: service_completed_successfully
|
||||
volumes: *superset-volumes
|
||||
environment:
|
||||
SUPERSET_LOG_LEVEL: "${SUPERSET_LOG_LEVEL:-info}"
|
||||
|
||||
superset-init:
|
||||
image: *superset-image
|
||||
@@ -86,9 +84,6 @@ services:
|
||||
volumes: *superset-volumes
|
||||
healthcheck:
|
||||
disable: true
|
||||
environment:
|
||||
SUPERSET_LOAD_EXAMPLES: "${SUPERSET_LOAD_EXAMPLES:-yes}"
|
||||
SUPERSET_LOG_LEVEL: "${SUPERSET_LOG_LEVEL:-info}"
|
||||
|
||||
superset-worker:
|
||||
image: *superset-image
|
||||
@@ -111,8 +106,6 @@ services:
|
||||
"CMD-SHELL",
|
||||
"celery -A superset.tasks.celery_app:app inspect ping -d celery@$$HOSTNAME",
|
||||
]
|
||||
environment:
|
||||
SUPERSET_LOG_LEVEL: "${SUPERSET_LOG_LEVEL:-info}"
|
||||
|
||||
superset-worker-beat:
|
||||
image: *superset-image
|
||||
@@ -131,8 +124,6 @@ services:
|
||||
volumes: *superset-volumes
|
||||
healthcheck:
|
||||
disable: true
|
||||
environment:
|
||||
SUPERSET_LOG_LEVEL: "${SUPERSET_LOG_LEVEL:-info}"
|
||||
|
||||
volumes:
|
||||
superset_home:
|
||||
|
||||
@@ -71,8 +71,6 @@ services:
|
||||
superset-init:
|
||||
condition: service_completed_successfully
|
||||
volumes: *superset-volumes
|
||||
environment:
|
||||
SUPERSET_LOG_LEVEL: "${SUPERSET_LOG_LEVEL:-info}"
|
||||
|
||||
superset-init:
|
||||
container_name: superset_init
|
||||
@@ -93,9 +91,6 @@ services:
|
||||
volumes: *superset-volumes
|
||||
healthcheck:
|
||||
disable: true
|
||||
environment:
|
||||
SUPERSET_LOAD_EXAMPLES: "${SUPERSET_LOAD_EXAMPLES:-yes}"
|
||||
SUPERSET_LOG_LEVEL: "${SUPERSET_LOG_LEVEL:-info}"
|
||||
|
||||
superset-worker:
|
||||
build:
|
||||
@@ -119,8 +114,6 @@ services:
|
||||
"CMD-SHELL",
|
||||
"celery -A superset.tasks.celery_app:app inspect ping -d celery@$$HOSTNAME",
|
||||
]
|
||||
environment:
|
||||
SUPERSET_LOG_LEVEL: "${SUPERSET_LOG_LEVEL:-info}"
|
||||
|
||||
superset-worker-beat:
|
||||
build:
|
||||
@@ -140,8 +133,6 @@ services:
|
||||
volumes: *superset-volumes
|
||||
healthcheck:
|
||||
disable: true
|
||||
environment:
|
||||
SUPERSET_LOG_LEVEL: "${SUPERSET_LOG_LEVEL:-info}"
|
||||
|
||||
volumes:
|
||||
superset_home:
|
||||
|
||||
@@ -104,9 +104,6 @@ services:
|
||||
superset-init:
|
||||
condition: service_completed_successfully
|
||||
volumes: *superset-volumes
|
||||
environment:
|
||||
CYPRESS_CONFIG: "${CYPRESS_CONFIG:-}"
|
||||
SUPERSET_LOG_LEVEL: "${SUPERSET_LOG_LEVEL:-info}"
|
||||
|
||||
superset-websocket:
|
||||
container_name: superset_websocket
|
||||
@@ -158,10 +155,6 @@ services:
|
||||
condition: service_started
|
||||
user: *superset-user
|
||||
volumes: *superset-volumes
|
||||
environment:
|
||||
CYPRESS_CONFIG: "${CYPRESS_CONFIG:-}"
|
||||
SUPERSET_LOAD_EXAMPLES: "${SUPERSET_LOAD_EXAMPLES:-yes}"
|
||||
SUPERSET_LOG_LEVEL: "${SUPERSET_LOG_LEVEL:-info}"
|
||||
healthcheck:
|
||||
disable: true
|
||||
|
||||
@@ -206,8 +199,6 @@ services:
|
||||
required: false
|
||||
environment:
|
||||
CELERYD_CONCURRENCY: 2
|
||||
CYPRESS_CONFIG: "${CYPRESS_CONFIG:-}"
|
||||
SUPERSET_LOG_LEVEL: "${SUPERSET_LOG_LEVEL:-info}"
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
superset-init:
|
||||
@@ -239,9 +230,6 @@ services:
|
||||
volumes: *superset-volumes
|
||||
healthcheck:
|
||||
disable: true
|
||||
environment:
|
||||
CYPRESS_CONFIG: "${CYPRESS_CONFIG:-}"
|
||||
SUPERSET_LOG_LEVEL: "${SUPERSET_LOG_LEVEL:-info}"
|
||||
|
||||
superset-tests-worker:
|
||||
build:
|
||||
@@ -262,7 +250,6 @@ services:
|
||||
REDIS_RESULTS_DB: 3
|
||||
REDIS_HOST: localhost
|
||||
CELERYD_CONCURRENCY: 8
|
||||
SUPERSET_LOG_LEVEL: "${SUPERSET_LOG_LEVEL:-info}"
|
||||
network_mode: host
|
||||
depends_on:
|
||||
superset-init:
|
||||
|
||||
@@ -72,7 +72,8 @@ are compatible with Superset.
|
||||
| [PostgreSQL](/docs/configuration/databases#postgres) | `pip install psycopg2` | `postgresql://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
|
||||
| [Presto](/docs/configuration/databases#presto) | `pip install pyhive` | `presto://{username}:{password}@{hostname}:{port}/{database}` |
|
||||
| [Rockset](/docs/configuration/databases#rockset) | `pip install rockset-sqlalchemy` | `rockset://<api_key>:@<api_server>` |
|
||||
| [SAP Hana](/docs/configuration/databases#hana) | `pip install hdbcli sqlalchemy-hana` or `pip install apache_superset[hana]` | `hana://{username}:{password}@{host}:{port}` |
|
||||
| [SAP Hana](/docs/configuration/databases#hana) | `pip install hdbcli sqlalchemy-hana` or `pip install apache_superset[hana]` | `hana://{username}:{password}@{host}:{port}` |
|
||||
| [SingleStore](/docs/configuration/databases#singlestore) | `pip install sqlalchemy-singlestoredb` | `singlestoredb://{username}:{password}@{host}:{port}/{database}` |
|
||||
| [StarRocks](/docs/configuration/databases#starrocks) | `pip install starrocks` | `starrocks://<User>:<Password>@<Host>:<Port>/<Catalog>.<Database>` |
|
||||
| [Snowflake](/docs/configuration/databases#snowflake) | `pip install snowflake-sqlalchemy` | `snowflake://{user}:{password}@{account}.{region}/{database}?role={role}&warehouse={warehouse}` |
|
||||
| SQLite | No additional library needed | `sqlite://path/to/file.db?check_same_thread=false` |
|
||||
@@ -1299,6 +1300,16 @@ You might have noticed that some special charecters are used in the above connec
|
||||
For more information about this check the [sqlalchemy documentation](https://docs.sqlalchemy.org/en/20/core/engines.html#escaping-special-characters-such-as-signs-in-passwords). Which says `When constructing a fully formed URL string to pass to create_engine(), special characters such as those that may be used in the user and password need to be URL encoded to be parsed correctly. This includes the @ sign.`
|
||||
:::
|
||||
|
||||
#### SingleStore
|
||||
|
||||
The recommended connector library for SingleStore is
|
||||
[sqlalchemy-singlestoredb](https://github.com/singlestore-labs/sqlalchemy-singlestoredb).
|
||||
|
||||
The expected connection string is formatted as follows:
|
||||
|
||||
```
|
||||
singlestoredb://{username}:{password}@{host}:{port}/{database}
|
||||
```
|
||||
|
||||
#### StarRocks
|
||||
|
||||
|
||||
@@ -250,6 +250,14 @@ Will be rendered as:
|
||||
SELECT * FROM users WHERE role IN ('admin', 'viewer')
|
||||
```
|
||||
|
||||
**Current User RLS Rules**
|
||||
|
||||
The `{{ current_user_rls_rules() }}` macro returns an array of RLS rules applied to the current dataset for the logged in user.
|
||||
|
||||
If you have caching enabled in your Superset configuration, then the list of RLS Rules will be used
|
||||
by Superset when calculating the cache key. A cache key is a unique identifier that determines if there's a
|
||||
cache hit in the future and Superset can retrieve cached data.
|
||||
|
||||
**Custom URL Parameters**
|
||||
|
||||
The `{{ url_param('custom_variable') }}` macro lets you define arbitrary URL
|
||||
|
||||
@@ -96,11 +96,16 @@ RUN . /app/.venv/bin/activate && \
|
||||
pymssql \
|
||||
# package needed for using single-sign on authentication:
|
||||
Authlib \
|
||||
# openpyxl to be able to upload Excel files
|
||||
openpyxl \
|
||||
# Pillow for Alerts & Reports to generate PDFs of dashboards
|
||||
Pillow \
|
||||
# install Playwright for taking screenshots for Alerts & Reports. This assumes the feature flag PLAYWRIGHT_REPORTS_AND_THUMBNAILS is enabled
|
||||
# That feature flag will default to True starting in 6.0.0
|
||||
# Playwright works only with Chrome.
|
||||
# If you are still using Selenium instead of Playwright, you would instead install here the selenium package and a headless browser & webdriver
|
||||
playwright \
|
||||
&& playwright install-deps \
|
||||
&& PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/playwright-browsers playwright install chromium
|
||||
|
||||
# Switch back to the superset user
|
||||
|
||||
@@ -7,6 +7,7 @@ sidebar_position: 2
|
||||
| CVE | Title | Affected |
|
||||
|:---------------|:-----------------------------------------------------------------------------------|---------:|
|
||||
| CVE-2025-27696 | Improper authorization leading to resource ownership takeover | < 4.1.2 |
|
||||
| CVE-2025-48912 | Improper authorization bypass on row level security via SQL Injection | < 4.1.2 |
|
||||
|
||||
#### Version 4.1.0
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"antd": "^5.25.1",
|
||||
"docusaurus-plugin-less": "^2.0.2",
|
||||
"less": "^4.3.0",
|
||||
"less-loader": "^11.0.0",
|
||||
"less-loader": "^12.3.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -39,7 +39,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.7.0",
|
||||
"@docusaurus/tsconfig": "^3.7.0",
|
||||
"@docusaurus/tsconfig": "^3.8.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
@@ -49,7 +49,7 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"prettier": "^2.0.0",
|
||||
"typescript": "~5.8.3",
|
||||
"webpack": "^5.99.8"
|
||||
"webpack": "^5.99.9"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -1935,10 +1935,10 @@
|
||||
fs-extra "^11.1.1"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/tsconfig@^3.7.0":
|
||||
version "3.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/tsconfig/-/tsconfig-3.7.0.tgz#654dcc524e25b8809af0f1b0b42485c18c047ab5"
|
||||
integrity sha512-vRsyj3yUZCjscgfgcFYjIsTcAru/4h4YH2/XAE8Rs7wWdnng98PgWKvP5ovVc4rmRpRg2WChVW0uOy2xHDvDBQ==
|
||||
"@docusaurus/tsconfig@^3.8.0":
|
||||
version "3.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/tsconfig/-/tsconfig-3.8.0.tgz#ea7ee0917e1562cf0a6e95e049c42f1f61351f32"
|
||||
integrity sha512-utLl48nNjSYBoq47RKukZ9fPLEX3nJWThzrujb0ndQQ1jc/gh4RhTRaAqItH9nImnsgGKmLMnyoMBpfGmoop+w==
|
||||
|
||||
"@docusaurus/types@3.7.0":
|
||||
version "3.7.0"
|
||||
@@ -8211,10 +8211,10 @@ layout-base@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/layout-base/-/layout-base-2.0.1.tgz#d0337913586c90f9c2c075292069f5c2da5dd285"
|
||||
integrity sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==
|
||||
|
||||
less-loader@^11.0.0:
|
||||
version "11.1.4"
|
||||
resolved "https://registry.npmjs.org/less-loader/-/less-loader-11.1.4.tgz"
|
||||
integrity sha512-6/GrYaB6QcW6Vj+/9ZPgKKs6G10YZai/l/eJ4SLwbzqNTBsAqt5hSLVF47TgsiBxV1P6eAU0GYRH3YRuQU9V3A==
|
||||
less-loader@^12.3.0:
|
||||
version "12.3.0"
|
||||
resolved "https://registry.yarnpkg.com/less-loader/-/less-loader-12.3.0.tgz#d4a00361568be86a97da3df4f16954b0d4c15340"
|
||||
integrity sha512-0M6+uYulvYIWs52y0LqN4+QM9TqWAohYSNTo4htE8Z7Cn3G/qQMEmktfHmyJT23k+20kU9zHH2wrfFXkxNLtVw==
|
||||
|
||||
less@^4.3.0:
|
||||
version "4.3.0"
|
||||
@@ -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.8:
|
||||
version "5.99.8"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.99.8.tgz#dd31a020b7c092d30c4c6d9a4edb95809e7f5946"
|
||||
integrity sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==
|
||||
webpack@^5.88.1, webpack@^5.95.0, webpack@^5.99.9:
|
||||
version "5.99.9"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.99.9.tgz#d7de799ec17d0cce3c83b70744b4aedb537d8247"
|
||||
integrity sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==
|
||||
dependencies:
|
||||
"@types/eslint-scope" "^3.7.7"
|
||||
"@types/estree" "^1.0.6"
|
||||
|
||||
@@ -32,6 +32,7 @@ authors = [
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
dependencies = [
|
||||
"backoff>=1.8.0",
|
||||
@@ -44,7 +45,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.4, <5.0.0",
|
||||
"flask-appbuilder>=4.7.0, <5.0.0",
|
||||
"flask-caching>=2.1.0, <3",
|
||||
"flask-compress>=1.13, <2.0",
|
||||
"flask-talisman>=1.0.0, <2.0",
|
||||
@@ -66,7 +67,7 @@ dependencies = [
|
||||
"markdown>=3.0",
|
||||
"msgpack>=1.0.0, <1.1",
|
||||
"nh3>=0.2.11, <0.3",
|
||||
"numpy>1.23.5, <2",
|
||||
"numpy>1.23.5, <2.3",
|
||||
"packaging",
|
||||
# --------------------------
|
||||
# pandas and related (wanting pandas[performance] without numba as it's 100+MB and not needed)
|
||||
@@ -93,8 +94,8 @@ dependencies = [
|
||||
"sqlalchemy>=1.4, <2",
|
||||
"sqlalchemy-utils>=0.38.3, <0.39",
|
||||
"sqlglot>=26.1.3, <27",
|
||||
"sqlparse>=0.5.0",
|
||||
"tabulate>=0.8.9, <0.9",
|
||||
# newer pandas needs 0.9+
|
||||
"tabulate>=0.9.0, <1.0",
|
||||
"typing-extensions>=4, <5",
|
||||
"waitress; sys_platform == 'win32'",
|
||||
"wtforms>=2.3.3, <4",
|
||||
@@ -166,6 +167,7 @@ prophet = ["prophet>=1.1.5, <2"]
|
||||
redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"]
|
||||
rockset = ["rockset-sqlalchemy>=0.0.1, <1"]
|
||||
shillelagh = ["shillelagh[all]>=1.2.18, <2"]
|
||||
singlestore = ["sqlalchemy-singlestoredb>=1.1.1, <2"]
|
||||
snowflake = ["snowflake-sqlalchemy>=1.2.4, <2"]
|
||||
spark = [
|
||||
"pyhive[hive]>=0.6.5;python_version<'3.11'",
|
||||
@@ -216,7 +218,7 @@ combine_as_imports = true
|
||||
include_trailing_comma = true
|
||||
line_length = 88
|
||||
known_first_party = "superset"
|
||||
known_third_party = "alembic, apispec, backoff, celery, click, colorama, cron_descriptor, croniter, cryptography, dateutil, deprecation, flask, flask_appbuilder, flask_babel, flask_caching, flask_compress, flask_jwt_extended, flask_login, flask_migrate, flask_sqlalchemy, flask_talisman, flask_testing, flask_wtf, freezegun, geohash, geopy, holidays, humanize, isodate, jinja2, jwt, markdown, markupsafe, marshmallow, msgpack, nh3, numpy, pandas, parameterized, parsedatetime, pgsanity, polyline, prison, progress, pyarrow, sqlalchemy_bigquery, pyhive, pyparsing, pytest, pytest_mock, pytz, redis, requests, selenium, setuptools, shillelagh, simplejson, slack, sqlalchemy, sqlalchemy_utils, sqlparse, typing_extensions, urllib3, werkzeug, wtforms, wtforms_json, yaml"
|
||||
known_third_party = "alembic, apispec, backoff, celery, click, colorama, cron_descriptor, croniter, cryptography, dateutil, deprecation, flask, flask_appbuilder, flask_babel, flask_caching, flask_compress, flask_jwt_extended, flask_login, flask_migrate, flask_sqlalchemy, flask_talisman, flask_testing, flask_wtf, freezegun, geohash, geopy, holidays, humanize, isodate, jinja2, jwt, markdown, markupsafe, marshmallow, msgpack, nh3, numpy, pandas, parameterized, parsedatetime, pgsanity, polyline, prison, progress, pyarrow, sqlalchemy_bigquery, pyhive, pyparsing, pytest, pytest_mock, pytz, redis, requests, selenium, setuptools, shillelagh, simplejson, slack, sqlalchemy, sqlalchemy_utils, typing_extensions, urllib3, werkzeug, wtforms, wtforms_json, yaml"
|
||||
multi_line_output = 3
|
||||
order_by_type = false
|
||||
|
||||
|
||||
13
requirements/README.md
Normal file
13
requirements/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
## Python dependency logic
|
||||
|
||||
In this folder, the `.in` files, in conjunction with the `../pyproject.toml` file (in the root of the repo) are used to generate the pinned requirements as `.txt` files.
|
||||
|
||||
To alter the pinned dependency, you can edit/alter the `.in` and `pyproject.toml` files, and then run the following command:
|
||||
|
||||
```bash
|
||||
./scripts/uv-pip-compile.sh
|
||||
```
|
||||
|
||||
This will generate the pinned requirements in the `.txt` files, which will be used in our CI/CD pipelines and in the Docker images.
|
||||
|
||||
We recommend to everyone in the community to use the pinned requirements in their local development environments, to ensure consistency across different environments, though we don't force requirements as part of our python package semantics to allow flexibility for users to install different versions of the dependencies if they wish.
|
||||
@@ -33,3 +33,6 @@ apispec>=6.0.0,<6.7.0
|
||||
# https://marshmallow-sqlalchemy.readthedocs.io/en/latest/changelog.html#id3
|
||||
# Opened this issue https://github.com/marshmallow-code/marshmallow-sqlalchemy/issues/665
|
||||
marshmallow-sqlalchemy>=1.3.0,<1.4.1
|
||||
|
||||
# needed for python 3.12 support
|
||||
openapi-schema-validator>=0.6.3
|
||||
|
||||
@@ -11,9 +11,7 @@ apispec==6.6.1
|
||||
apsw==3.49.2.0
|
||||
# via shillelagh
|
||||
async-timeout==4.0.3
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# redis
|
||||
# via -r requirements/base.in
|
||||
attrs==25.3.0
|
||||
# via
|
||||
# cattrs
|
||||
@@ -99,11 +97,6 @@ email-validator==2.2.0
|
||||
# via flask-appbuilder
|
||||
et-xmlfile==2.0.0
|
||||
# via openpyxl
|
||||
exceptiongroup==1.3.0
|
||||
# via
|
||||
# cattrs
|
||||
# trio
|
||||
# trio-websocket
|
||||
flask==2.3.3
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
@@ -118,7 +111,7 @@ flask==2.3.3
|
||||
# flask-session
|
||||
# flask-sqlalchemy
|
||||
# flask-wtf
|
||||
flask-appbuilder==4.6.4
|
||||
flask-appbuilder==4.7.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
flask-babel==2.0.0
|
||||
# via flask-appbuilder
|
||||
@@ -189,9 +182,13 @@ jinja2==3.1.6
|
||||
jsonpath-ng==1.7.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
jsonschema==4.23.0
|
||||
# via flask-appbuilder
|
||||
# via
|
||||
# flask-appbuilder
|
||||
# openapi-schema-validator
|
||||
jsonschema-specifications==2025.4.1
|
||||
# via jsonschema
|
||||
# via
|
||||
# jsonschema
|
||||
# openapi-schema-validator
|
||||
kombu==5.5.3
|
||||
# via celery
|
||||
korean-lunar-calendar==0.3.1
|
||||
@@ -238,6 +235,8 @@ numpy==1.26.4
|
||||
# pandas
|
||||
odfpy==1.4.1
|
||||
# via pandas
|
||||
openapi-schema-validator==0.6.3
|
||||
# via -r requirements/base.in
|
||||
openpyxl==3.1.5
|
||||
# via pandas
|
||||
ordered-set==4.1.0
|
||||
@@ -336,6 +335,8 @@ requests==2.32.3
|
||||
# shillelagh
|
||||
requests-cache==1.2.1
|
||||
# via shillelagh
|
||||
rfc3339-validator==0.1.4
|
||||
# via openapi-schema-validator
|
||||
rich==13.9.4
|
||||
# via flask-limiter
|
||||
rpds-py==0.25.0
|
||||
@@ -354,6 +355,8 @@ six==1.17.0
|
||||
# via
|
||||
# prison
|
||||
# python-dateutil
|
||||
# rfc3339-validator
|
||||
# url-normalize
|
||||
# wtforms-json
|
||||
slack-sdk==3.35.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -376,11 +379,9 @@ sqlalchemy-utils==0.38.3
|
||||
# flask-appbuilder
|
||||
sqlglot==26.17.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
sqlparse==0.5.3
|
||||
# via apache-superset (pyproject.toml)
|
||||
sshtunnel==0.4.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
tabulate==0.8.10
|
||||
tabulate==0.9.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
trio==0.30.0
|
||||
# via
|
||||
@@ -392,12 +393,9 @@ typing-extensions==4.13.2
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# alembic
|
||||
# cattrs
|
||||
# exceptiongroup
|
||||
# limits
|
||||
# pyopenssl
|
||||
# referencing
|
||||
# rich
|
||||
# selenium
|
||||
# shillelagh
|
||||
tzdata==2025.2
|
||||
|
||||
@@ -18,10 +18,6 @@ apsw==3.49.2.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# shillelagh
|
||||
async-timeout==4.0.3
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# redis
|
||||
attrs==25.3.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
@@ -176,13 +172,6 @@ et-xmlfile==2.0.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# openpyxl
|
||||
exceptiongroup==1.3.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# cattrs
|
||||
# pytest
|
||||
# trio
|
||||
# trio-websocket
|
||||
filelock==3.12.2
|
||||
# via virtualenv
|
||||
flask==2.3.3
|
||||
@@ -202,7 +191,7 @@ flask==2.3.3
|
||||
# flask-sqlalchemy
|
||||
# flask-testing
|
||||
# flask-wtf
|
||||
flask-appbuilder==4.6.4
|
||||
flask-appbuilder==4.7.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -480,7 +469,9 @@ odfpy==1.4.1
|
||||
# -c requirements/base.txt
|
||||
# pandas
|
||||
openapi-schema-validator==0.6.3
|
||||
# via openapi-spec-validator
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# openapi-spec-validator
|
||||
openapi-spec-validator==0.7.1
|
||||
# via apache-superset
|
||||
openpyxl==3.1.5
|
||||
@@ -727,7 +718,9 @@ requests-cache==1.2.1
|
||||
requests-oauthlib==2.0.0
|
||||
# via google-auth-oauthlib
|
||||
rfc3339-validator==0.1.4
|
||||
# via openapi-schema-validator
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# openapi-schema-validator
|
||||
rich==13.9.4
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
@@ -805,24 +798,16 @@ sqlglot==26.17.1
|
||||
# apache-superset
|
||||
sqloxide==0.1.51
|
||||
# via apache-superset
|
||||
sqlparse==0.5.3
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
sshtunnel==0.4.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
statsd==4.0.1
|
||||
# via apache-superset
|
||||
tabulate==0.8.10
|
||||
tabulate==0.9.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
tomli==2.2.1
|
||||
# via
|
||||
# coverage
|
||||
# pytest
|
||||
tqdm==4.67.1
|
||||
# via
|
||||
# cmdstanpy
|
||||
@@ -843,12 +828,9 @@ typing-extensions==4.13.2
|
||||
# -c requirements/base.txt
|
||||
# alembic
|
||||
# apache-superset
|
||||
# cattrs
|
||||
# exceptiongroup
|
||||
# limits
|
||||
# pyopenssl
|
||||
# referencing
|
||||
# rich
|
||||
# selenium
|
||||
# shillelagh
|
||||
tzdata==2025.2
|
||||
|
||||
358
superset-frontend/package-lock.json
generated
358
superset-frontend/package-lock.json
generated
@@ -194,7 +194,6 @@
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"@types/classnames": "^2.2.10",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
"@types/enzyme": "^3.10.18",
|
||||
"@types/fetch-mock": "^7.3.2",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/jquery": "^3.5.8",
|
||||
@@ -224,9 +223,8 @@
|
||||
"@types/yargs": "12 - 18",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.8.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-loader": "^9.1.3",
|
||||
"babel-loader": "^10.0.0",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
@@ -236,8 +234,6 @@
|
||||
"cross-env": "^7.0.3",
|
||||
"css-loader": "^7.1.2",
|
||||
"css-minimizer-webpack-plugin": "^7.0.2",
|
||||
"enzyme": "^3.11.0",
|
||||
"enzyme-matchers": "^7.1.2",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-prettier": "^7.2.0",
|
||||
@@ -14053,55 +14049,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@wojtekmaj/enzyme-adapter-react-17": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@wojtekmaj/enzyme-adapter-react-17/-/enzyme-adapter-react-17-0.8.0.tgz",
|
||||
"integrity": "sha512-zeUGfQRziXW7R7skzNuJyi01ZwuKCH8WiBNnTgUJwdS/CURrJwAhWsfW7nG7E30ak8Pu3ZwD9PlK9skBfAoOBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@wojtekmaj/enzyme-adapter-utils": "^0.2.0",
|
||||
"enzyme-shallow-equal": "^1.0.0",
|
||||
"has": "^1.0.0",
|
||||
"prop-types": "^15.7.0",
|
||||
"react-is": "^17.0.0",
|
||||
"react-test-renderer": "^17.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/enzyme-adapter-react-17?sponsor=1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"enzyme": "^3.0.0",
|
||||
"react": "^17.0.0-0",
|
||||
"react-dom": "^17.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@wojtekmaj/enzyme-adapter-react-17/node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@wojtekmaj/enzyme-adapter-utils": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@wojtekmaj/enzyme-adapter-utils/-/enzyme-adapter-utils-0.2.0.tgz",
|
||||
"integrity": "sha512-ZvZm9kZxZEKAbw+M1/Q3iDuqQndVoN8uLnxZ8bzxm7KgGTBejrGRoJAp8f1EN8eoO3iAjBNEQnTDW/H4Ekb0FQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function.prototype.name": "^1.1.0",
|
||||
"has": "^1.0.0",
|
||||
"object.fromentries": "^2.0.0",
|
||||
"prop-types": "^15.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/enzyme-adapter-utils?sponsor=1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.10",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
|
||||
@@ -15647,6 +15594,8 @@
|
||||
"integrity": "sha512-r+mCJ7zXgXElgR4IRC+fkvNCeoaavWBs6EdCso5Tbcf+iEMKzBU/His60lt34WEZ9vlb8wDkZvQGcVI5GwkfoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.7",
|
||||
"define-properties": "^1.2.1",
|
||||
@@ -16089,142 +16038,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/babel-loader": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz",
|
||||
"integrity": "sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==",
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.0.0.tgz",
|
||||
"integrity": "sha512-z8jt+EdS61AMw22nSfoNJAZ0vrtmhPRVi6ghL3rCeRZI8cdNYFiV5xeV3HbE7rlZZNmGH8BVccwWt8/ED0QOHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"find-cache-dir": "^4.0.0",
|
||||
"schema-utils": "^4.0.0"
|
||||
"find-up": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.15.0"
|
||||
"node": "^18.20.0 || ^20.10.0 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.12.0",
|
||||
"webpack": ">=5"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-loader/node_modules/find-cache-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"common-path-prefix": "^3.0.0",
|
||||
"pkg-dir": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-loader/node_modules/find-up": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz",
|
||||
"integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^7.1.0",
|
||||
"path-exists": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-loader/node_modules/locate-path": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz",
|
||||
"integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-loader/node_modules/p-limit": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz",
|
||||
"integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yocto-queue": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-loader/node_modules/p-locate": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz",
|
||||
"integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-loader/node_modules/path-exists": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
|
||||
"integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-loader/node_modules/pkg-dir": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz",
|
||||
"integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"find-up": "^6.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-loader/node_modules/yocto-queue": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz",
|
||||
"integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
"webpack": ">=5.61.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-dynamic-import-node": {
|
||||
@@ -17914,13 +17741,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/circular-json-es6": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/circular-json-es6/-/circular-json-es6-2.0.2.tgz",
|
||||
"integrity": "sha512-ODYONMMNb3p658Zv+Pp+/XPa5s6q7afhz3Tzyvo+VRh9WIrJ64J76ZC4GQxnlye/NesTn09jvOiuE8+xxfpwhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/citty": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
@@ -18312,13 +18132,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/common-path-prefix": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz",
|
||||
"integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/common-tags": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz",
|
||||
@@ -20227,28 +20040,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-equal-ident": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/deep-equal-ident/-/deep-equal-ident-1.1.1.tgz",
|
||||
"integrity": "sha512-aWv7VhTl/Lju1zenOD3E1w8PpUVrTDbwXCHtbSNr+p/uadr49Y1P1ld0W3Pl6gbvIbiRjoCVsqw70UupCNGh6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.isequal": "^3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-equal-ident/node_modules/lodash.isequal": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-3.0.4.tgz",
|
||||
"integrity": "sha512-Bsu5fP9Omd+HBk2Dz8qp4BHbC+83DBykZ87Lz1JmPKTVNy4Q0XQVtUrbfXVAK/udQrWNcGStcKSA9yj/Zkm3TQ==",
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash._baseisequal": "^3.0.0",
|
||||
"lodash._bindcallback": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
@@ -20645,7 +20436,9 @@
|
||||
"resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
|
||||
"integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/distributions": {
|
||||
"version": "2.2.0",
|
||||
@@ -21333,6 +21126,8 @@
|
||||
"integrity": "sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"array.prototype.flat": "^1.2.3",
|
||||
"cheerio": "^1.0.0-rc.3",
|
||||
@@ -21361,26 +21156,14 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/enzyme-matchers": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/enzyme-matchers/-/enzyme-matchers-7.1.2.tgz",
|
||||
"integrity": "sha512-03WqAg2XDl7id9rARIO97HQ1JIw9F2heJ3R4meGu/13hx0ULTDEgl0E67MGl2Uq1jq1DyRnJfto1/VSzskdV5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"circular-json-es6": "^2.0.1",
|
||||
"deep-equal-ident": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"enzyme": ">=3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/enzyme-shallow-equal": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.7.tgz",
|
||||
"integrity": "sha512-/um0GFqUXnpM9SvKtje+9Tjoz3f1fpBC3eXRFrNs8kpYn69JljciYP7KZTqM/YQbUY9KUjvKB4jo/q+L6WGGvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.0",
|
||||
"object-is": "^1.1.5"
|
||||
@@ -23218,9 +23001,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fastify": {
|
||||
"version": "4.29.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.0.tgz",
|
||||
"integrity": "sha512-MaaUHUGcCgC8fXQDsDtioaCcag1fmPJ9j64vAKunqZF4aSub040ZGi/ag8NGE2714yREPOKZuHCfpPzuUD3UQQ==",
|
||||
"version": "4.29.1",
|
||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz",
|
||||
"integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -26391,6 +26174,8 @@
|
||||
"integrity": "sha512-6XMlxrAFX4UEEGxctfFnmrFaaZFNf9i5fNuV5wZ3WWQ4FVaNP1aX1LkX9j2mfEx1NpjeE/rL3nmgEn23GdFmrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"array.prototype.filter": "^1.0.0",
|
||||
"call-bind": "^1.0.2"
|
||||
@@ -31660,32 +31445,6 @@
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash._baseisequal": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/lodash._baseisequal/-/lodash._baseisequal-3.0.7.tgz",
|
||||
"integrity": "sha512-U+3GsNEZj9ebI03ncLC2pLmYVjgtYZEwdkAPO7UGgtGvAz36JVFPAQUufpSaVL93Cz5arc6JGRKZRhaOhyVJYA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.isarray": "^3.0.0",
|
||||
"lodash.istypedarray": "^3.0.0",
|
||||
"lodash.keys": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash._bindcallback": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz",
|
||||
"integrity": "sha512-2wlI0JRAGX8WEf4Gm1p/mv/SZ+jLijpj0jyaE/AXeuQphzCgD8ZQW4oSpoN8JAopujOFGU3KMuq7qfHBWlGpjQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash._getnative": {
|
||||
"version": "3.9.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz",
|
||||
"integrity": "sha512-RrL9VxMEPyDMHOd9uFbvMe8X55X16/cGM5IgOKgRElQZutpX89iS6vwl64duTV1/16w5JY7tuFNXqoekmh1EmA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.curry": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz",
|
||||
@@ -31704,14 +31463,17 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz",
|
||||
"integrity": "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.flattendeep": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
|
||||
"integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.get": {
|
||||
"version": "4.4.2",
|
||||
@@ -31719,20 +31481,6 @@
|
||||
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isarray": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz",
|
||||
"integrity": "sha512-JwObCrNJuT0Nnbuecmqr5DgtuBppuCvGD9lxjFpAzwnVtdGoDQ1zig+5W8k5/6Gcn0gZ3936HDAlGd28i7sOGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
@@ -31753,25 +31501,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.istypedarray": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.istypedarray/-/lodash.istypedarray-3.0.6.tgz",
|
||||
"integrity": "sha512-lGWJ6N8AA3KSv+ZZxlTdn4f6A7kMfpJboeyvbFdE7IU9YAgweODqmOgdUHOA+c6lVWeVLysdaxciFXi+foVsWw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.keys": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz",
|
||||
"integrity": "sha512-CuBsapFjcubOGMn3VD+24HOAPxM79tH+V6ivJL3CHYjtrawauDJHUk//Yew9Hvc6e9rbCrURGk8z6PC+8WJBfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash._getnative": "^3.0.0",
|
||||
"lodash.isarguments": "^3.0.0",
|
||||
"lodash.isarray": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.memoize": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||
@@ -34040,7 +33769,9 @@
|
||||
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz",
|
||||
"integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/mousetrap": {
|
||||
"version": "1.6.5",
|
||||
@@ -34188,6 +33919,8 @@
|
||||
"integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^2.19.0",
|
||||
"moo": "^0.5.0",
|
||||
@@ -34210,7 +33943,9 @@
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/needle": {
|
||||
"version": "3.3.1",
|
||||
@@ -38405,8 +38140,8 @@
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
@@ -38416,7 +38151,9 @@
|
||||
"resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
|
||||
"integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
"license": "CC0-1.0",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ramda": {
|
||||
"version": "0.29.0",
|
||||
@@ -38434,6 +38171,8 @@
|
||||
"integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"discontinuous-range": "1.0.0",
|
||||
"ret": "~0.1.10"
|
||||
@@ -39865,8 +39604,9 @@
|
||||
"version": "16.15.0",
|
||||
"resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz",
|
||||
"integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.12.0 || ^17.0.0 || ^18.0.0"
|
||||
@@ -39960,8 +39700,9 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz",
|
||||
"integrity": "sha512-yaQ9cB89c17PUb0x6UfWRs7kQCorVdHlutU1boVPEsB8IDZH6n9tHxMacc3y0JoXOJUsZb/t/Mb8FUWMKaM7iQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^17.0.2",
|
||||
@@ -39976,8 +39717,9 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
@@ -42114,6 +41856,8 @@
|
||||
"integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
@@ -42286,6 +42030,8 @@
|
||||
"integrity": "sha512-nDG1rZeP6oFTLN6yNDV/uiAvs1+FS/KlrEwh7+y7dpuApDBy6bI2HTBcc0/V8lv9OTqfyD34eF7au2pm8aBbhA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lodash.flattendeep": "^4.4.0",
|
||||
"nearley": "^2.7.10"
|
||||
@@ -49898,7 +49644,7 @@
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@babel/preset-typescript": "^7.23.3",
|
||||
"@storybook/react-webpack5": "8.2.9",
|
||||
"babel-loader": "^9.1.3",
|
||||
"babel-loader": "^10.0.0",
|
||||
"fork-ts-checker-webpack-plugin": "^9.0.2",
|
||||
"ts-loader": "^9.5.2",
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -261,7 +261,6 @@
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"@types/classnames": "^2.2.10",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
"@types/enzyme": "^3.10.18",
|
||||
"@types/fetch-mock": "^7.3.2",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/jquery": "^3.5.8",
|
||||
@@ -291,9 +290,8 @@
|
||||
"@types/yargs": "12 - 18",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.8.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-loader": "^9.1.3",
|
||||
"babel-loader": "^10.0.0",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
@@ -303,8 +301,6 @@
|
||||
"cross-env": "^7.0.3",
|
||||
"css-loader": "^7.1.2",
|
||||
"css-minimizer-webpack-plugin": "^7.0.2",
|
||||
"enzyme": "^3.11.0",
|
||||
"enzyme-matchers": "^7.1.2",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-prettier": "^7.2.0",
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@types/enzyme": "^3.10.18",
|
||||
"@types/react": "*",
|
||||
"lodash": "^4.17.21",
|
||||
"prop-types": "^15.8.1"
|
||||
|
||||
@@ -30,7 +30,7 @@ export const aggregationOperator: PostProcessingFactory<
|
||||
> = (formData: QueryFormData, queryObject) => {
|
||||
const { aggregation = 'LAST_VALUE' } = formData;
|
||||
|
||||
if (aggregation === 'LAST_VALUE') {
|
||||
if (aggregation === 'LAST_VALUE' || aggregation === 'raw') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ export const aggregationControl = {
|
||||
clearable: false,
|
||||
renderTrigger: false,
|
||||
choices: [
|
||||
['raw', t('None')],
|
||||
['LAST_VALUE', t('Last Value')],
|
||||
['sum', t('Total (Sum)')],
|
||||
['mean', t('Average (Mean)')],
|
||||
@@ -77,7 +78,9 @@ export const aggregationControl = {
|
||||
['max', t('Maximum')],
|
||||
['median', t('Median')],
|
||||
],
|
||||
description: t('Select an aggregation method to apply to the metric.'),
|
||||
description: t(
|
||||
'Aggregation method used to compute the Big Number from the Trendline.For non-additive metrics like ratios, averages, distinct counts, etc use NONE.',
|
||||
),
|
||||
provideFormDataToProps: true,
|
||||
mapStateToProps: ({ form_data }: ControlPanelState) => ({
|
||||
value: form_data.aggregation || 'LAST_VALUE',
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
"@types/d3-scale": "^2.1.1",
|
||||
"@types/d3-time": "^3.0.4",
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/enzyme": "^3.10.18",
|
||||
"@types/fetch-mock": "^7.3.8",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/math-expression-evaluator": "^1.3.3",
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@babel/preset-typescript": "^7.23.3",
|
||||
"@storybook/react-webpack5": "8.2.9",
|
||||
"babel-loader": "^9.1.3",
|
||||
"babel-loader": "^10.0.0",
|
||||
"fork-ts-checker-webpack-plugin": "^9.0.2",
|
||||
"ts-loader": "^9.5.2",
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { QueryFormData } from '@superset-ui/core';
|
||||
import buildQuery from './buildQuery';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
getXAxisColumn: jest.fn(() => 'order_date'),
|
||||
isXAxisSet: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/chart-controls', () => ({
|
||||
pivotOperator: jest.fn(() => ({ operation: 'pivot' })),
|
||||
aggregationOperator: jest.fn(formData => {
|
||||
if (formData.aggregation === 'LAST_VALUE' || !formData.aggregation) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
operation: 'aggregation',
|
||||
options: { operator: formData.aggregation },
|
||||
};
|
||||
}),
|
||||
flattenOperator: jest.fn(() => ({ operation: 'flatten' })),
|
||||
resampleOperator: jest.fn(() => ({ operation: 'resample' })),
|
||||
rollingWindowOperator: jest.fn(() => ({ operation: 'rolling' })),
|
||||
}));
|
||||
|
||||
describe('BigNumberWithTrendline buildQuery', () => {
|
||||
const baseFormData: QueryFormData = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'big_number',
|
||||
metric: 'custom_metric',
|
||||
aggregation: null,
|
||||
};
|
||||
|
||||
it('creates raw metric query when aggregation is null', () => {
|
||||
const queryContext = buildQuery({ ...baseFormData });
|
||||
const bigNumberQuery = queryContext.queries[1];
|
||||
|
||||
expect(bigNumberQuery.post_processing).toEqual([{ operation: 'pivot' }]);
|
||||
expect(bigNumberQuery.is_timeseries).toBe(true);
|
||||
});
|
||||
|
||||
it('adds aggregation operator when aggregation is "sum"', () => {
|
||||
const queryContext = buildQuery({ ...baseFormData, aggregation: 'sum' });
|
||||
const bigNumberQuery = queryContext.queries[1];
|
||||
|
||||
expect(bigNumberQuery.post_processing).toEqual([
|
||||
{ operation: 'pivot' },
|
||||
{ operation: 'aggregation', options: { operator: 'sum' } },
|
||||
]);
|
||||
expect(bigNumberQuery.is_timeseries).toBe(true);
|
||||
});
|
||||
|
||||
it('skips aggregation when aggregation is LAST_VALUE', () => {
|
||||
const queryContext = buildQuery({
|
||||
...baseFormData,
|
||||
aggregation: 'LAST_VALUE',
|
||||
});
|
||||
const bigNumberQuery = queryContext.queries[1];
|
||||
|
||||
expect(bigNumberQuery.post_processing).toEqual([{ operation: 'pivot' }]);
|
||||
expect(bigNumberQuery.is_timeseries).toBe(true);
|
||||
});
|
||||
|
||||
it('always returns two queries', () => {
|
||||
const queryContext = buildQuery({ ...baseFormData });
|
||||
expect(queryContext.queries.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildQueryContext,
|
||||
ensureIsArray,
|
||||
@@ -32,15 +33,17 @@ import {
|
||||
} from '@superset-ui/chart-controls';
|
||||
|
||||
export default function buildQuery(formData: QueryFormData) {
|
||||
const isRawMetric = formData.aggregation === 'raw';
|
||||
|
||||
const timeColumn = isXAxisSet(formData)
|
||||
? ensureIsArray(getXAxisColumn(formData))
|
||||
: [];
|
||||
|
||||
return buildQueryContext(formData, baseQueryObject => [
|
||||
{
|
||||
...baseQueryObject,
|
||||
columns: [
|
||||
...(isXAxisSet(formData)
|
||||
? ensureIsArray(getXAxisColumn(formData))
|
||||
: []),
|
||||
],
|
||||
...(isXAxisSet(formData) ? {} : { is_timeseries: true }),
|
||||
columns: [...timeColumn],
|
||||
...(timeColumn.length ? {} : { is_timeseries: true }),
|
||||
post_processing: [
|
||||
pivotOperator(formData, baseQueryObject),
|
||||
rollingWindowOperator(formData, baseQueryObject),
|
||||
@@ -48,19 +51,16 @@ export default function buildQuery(formData: QueryFormData) {
|
||||
flattenOperator(formData, baseQueryObject),
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
...baseQueryObject,
|
||||
columns: [
|
||||
...(isXAxisSet(formData)
|
||||
? ensureIsArray(getXAxisColumn(formData))
|
||||
: []),
|
||||
],
|
||||
...(isXAxisSet(formData) ? {} : { is_timeseries: true }),
|
||||
post_processing: [
|
||||
pivotOperator(formData, baseQueryObject),
|
||||
aggregationOperator(formData, baseQueryObject),
|
||||
],
|
||||
columns: [...(isRawMetric ? [] : timeColumn)],
|
||||
is_timeseries: !isRawMetric,
|
||||
post_processing: isRawMetric
|
||||
? []
|
||||
: [
|
||||
pivotOperator(formData, baseQueryObject),
|
||||
aggregationOperator(formData, baseQueryObject),
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { ensureIsArray, t } from '@superset-ui/core';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import {
|
||||
ControlPanelsContainerProps,
|
||||
ControlPanelConfig,
|
||||
ControlPanelSectionConfig,
|
||||
ControlSetRow,
|
||||
@@ -27,6 +28,8 @@ import {
|
||||
getStandardizedControls,
|
||||
sections,
|
||||
sharedControls,
|
||||
DEFAULT_SORT_SERIES_DATA,
|
||||
SORT_SERIES_CHOICES,
|
||||
} from '@superset-ui/chart-controls';
|
||||
|
||||
import { DEFAULT_FORM_DATA } from './types';
|
||||
@@ -196,6 +199,23 @@ function createCustomizeSection(
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: `only_total${controlSuffix}`,
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Only Total'),
|
||||
default: true,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Only show the total value on the stacked chart, and not show on the selected category',
|
||||
),
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
Boolean(controls?.show_value?.value) &&
|
||||
Boolean(controls?.stack?.value),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: `opacity${controlSuffix}`,
|
||||
@@ -258,6 +278,35 @@ function createCustomizeSection(
|
||||
},
|
||||
},
|
||||
],
|
||||
[<ControlSubSectionHeader>{t('Series Order')}</ControlSubSectionHeader>],
|
||||
[
|
||||
{
|
||||
name: `sort_series_type${controlSuffix}`,
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
freeForm: false,
|
||||
label: t('Sort Series By'),
|
||||
choices: SORT_SERIES_CHOICES,
|
||||
default: DEFAULT_SORT_SERIES_DATA.sort_series_type,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Based on what should series be ordered on the chart and legend',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: `sort_series_ascending${controlSuffix}`,
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Sort Series Ascending'),
|
||||
default: DEFAULT_SORT_SERIES_DATA.sort_series_ascending,
|
||||
renderTrigger: true,
|
||||
description: t('Sort series in ascending order'),
|
||||
},
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ import {
|
||||
getXAxisFormatter,
|
||||
getYAxisFormatter,
|
||||
} from '../utils/formatters';
|
||||
import { getMetricDisplayName } from '../utils/metricDisplayName';
|
||||
|
||||
const getFormatter = (
|
||||
customFormatters: Record<string, ValueFormatter>,
|
||||
@@ -172,6 +173,8 @@ export default function transformProps(
|
||||
showLegend,
|
||||
showValue,
|
||||
showValueB,
|
||||
onlyTotal,
|
||||
onlyTotalB,
|
||||
stack,
|
||||
stackB,
|
||||
truncateXAxis,
|
||||
@@ -202,6 +205,10 @@ export default function transformProps(
|
||||
yAxisTitleMargin,
|
||||
yAxisTitlePosition,
|
||||
sliceId,
|
||||
sortSeriesType,
|
||||
sortSeriesTypeB,
|
||||
sortSeriesAscending,
|
||||
sortSeriesAscendingB,
|
||||
timeGrainSqla,
|
||||
percentageThreshold,
|
||||
metrics = [],
|
||||
@@ -222,14 +229,42 @@ export default function transformProps(
|
||||
}
|
||||
|
||||
const rebasedDataA = rebaseForecastDatum(data1, verboseMap);
|
||||
const [rawSeriesA] = extractSeries(rebasedDataA, {
|
||||
const { totalStackedValues, thresholdValues } = extractDataTotalValues(
|
||||
rebasedDataA,
|
||||
{
|
||||
stack,
|
||||
percentageThreshold,
|
||||
xAxisCol: xAxisLabel,
|
||||
},
|
||||
);
|
||||
|
||||
const MetricDisplayNameA = getMetricDisplayName(metrics[0], verboseMap);
|
||||
const MetricDisplayNameB = getMetricDisplayName(metricsB[0], verboseMap);
|
||||
|
||||
const [rawSeriesA, sortedTotalValuesA] = extractSeries(rebasedDataA, {
|
||||
fillNeighborValue: stack ? 0 : undefined,
|
||||
xAxis: xAxisLabel,
|
||||
sortSeriesType,
|
||||
sortSeriesAscending,
|
||||
stack,
|
||||
totalStackedValues,
|
||||
});
|
||||
const rebasedDataB = rebaseForecastDatum(data2, verboseMap);
|
||||
const [rawSeriesB] = extractSeries(rebasedDataB, {
|
||||
const {
|
||||
totalStackedValues: totalStackedValuesB,
|
||||
thresholdValues: thresholdValuesB,
|
||||
} = extractDataTotalValues(rebasedDataB, {
|
||||
stack: Boolean(stackB),
|
||||
percentageThreshold,
|
||||
xAxisCol: xAxisLabel,
|
||||
});
|
||||
const [rawSeriesB, sortedTotalValuesB] = extractSeries(rebasedDataB, {
|
||||
fillNeighborValue: stackB ? 0 : undefined,
|
||||
xAxis: xAxisLabel,
|
||||
sortSeriesType: sortSeriesTypeB,
|
||||
sortSeriesAscending: sortSeriesAscendingB,
|
||||
stack: Boolean(stackB),
|
||||
totalStackedValues: totalStackedValuesB,
|
||||
});
|
||||
|
||||
const dataTypes = getColtypesMapping(queriesData[0]);
|
||||
@@ -287,25 +322,11 @@ export default function transformProps(
|
||||
);
|
||||
const showValueIndexesA = extractShowValueIndexes(rawSeriesA, {
|
||||
stack,
|
||||
onlyTotal,
|
||||
});
|
||||
const showValueIndexesB = extractShowValueIndexes(rawSeriesB, {
|
||||
stack,
|
||||
});
|
||||
const { totalStackedValues, thresholdValues } = extractDataTotalValues(
|
||||
rebasedDataA,
|
||||
{
|
||||
stack,
|
||||
percentageThreshold,
|
||||
xAxisCol: xAxisLabel,
|
||||
},
|
||||
);
|
||||
const {
|
||||
totalStackedValues: totalStackedValuesB,
|
||||
thresholdValues: thresholdValuesB,
|
||||
} = extractDataTotalValues(rebasedDataB, {
|
||||
stack: Boolean(stackB),
|
||||
percentageThreshold,
|
||||
xAxisCol: xAxisLabel,
|
||||
onlyTotal,
|
||||
});
|
||||
|
||||
annotationLayers
|
||||
@@ -373,6 +394,12 @@ export default function transformProps(
|
||||
const seriesName = inverted[entryName] || entryName;
|
||||
const colorScaleKey = getOriginalSeries(seriesName, array);
|
||||
|
||||
let displayName = `${entryName} (Query A)`;
|
||||
|
||||
if (groupby.length > 0) {
|
||||
displayName = `${MetricDisplayNameA} (Query A), ${entryName}`;
|
||||
}
|
||||
|
||||
const seriesFormatter = getFormatter(
|
||||
customFormatters,
|
||||
formatter,
|
||||
@@ -382,7 +409,10 @@ export default function transformProps(
|
||||
);
|
||||
|
||||
const transformedSeries = transformSeries(
|
||||
entry,
|
||||
{
|
||||
...entry,
|
||||
id: `${displayName || ''}`,
|
||||
},
|
||||
colorScale,
|
||||
colorScaleKey,
|
||||
{
|
||||
@@ -392,6 +422,7 @@ export default function transformProps(
|
||||
areaOpacity: opacity,
|
||||
seriesType,
|
||||
showValue,
|
||||
onlyTotal,
|
||||
stack: Boolean(stack),
|
||||
stackIdSuffix: '\na',
|
||||
yAxisIndex,
|
||||
@@ -406,8 +437,8 @@ export default function transformProps(
|
||||
formatter: seriesFormatter,
|
||||
})
|
||||
: seriesFormatter,
|
||||
totalStackedValues: sortedTotalValuesA,
|
||||
showValueIndexes: showValueIndexesA,
|
||||
totalStackedValues,
|
||||
thresholdValues,
|
||||
timeShiftColor,
|
||||
},
|
||||
@@ -421,6 +452,12 @@ export default function transformProps(
|
||||
const seriesName = `${seriesEntry} (1)`;
|
||||
const colorScaleKey = getOriginalSeries(seriesEntry, array);
|
||||
|
||||
let displayName = `${entryName} (Query B)`;
|
||||
|
||||
if (groupbyB.length > 0) {
|
||||
displayName = `${MetricDisplayNameB} (Query B), ${entryName}`;
|
||||
}
|
||||
|
||||
const seriesFormatter = getFormatter(
|
||||
customFormattersSecondary,
|
||||
formatterSecondary,
|
||||
@@ -430,7 +467,11 @@ export default function transformProps(
|
||||
);
|
||||
|
||||
const transformedSeries = transformSeries(
|
||||
entry,
|
||||
{
|
||||
...entry,
|
||||
id: `${displayName || ''}`,
|
||||
},
|
||||
|
||||
colorScale,
|
||||
colorScaleKey,
|
||||
{
|
||||
@@ -440,13 +481,12 @@ export default function transformProps(
|
||||
areaOpacity: opacityB,
|
||||
seriesType: seriesTypeB,
|
||||
showValue: showValueB,
|
||||
onlyTotal: onlyTotalB,
|
||||
stack: Boolean(stackB),
|
||||
stackIdSuffix: '\nb',
|
||||
yAxisIndex: yAxisIndexB,
|
||||
filterState,
|
||||
seriesKey: primarySeries.has(entry.name as string)
|
||||
? `${entry.name} (1)`
|
||||
: entry.name,
|
||||
seriesKey: entry.name,
|
||||
sliceId,
|
||||
queryIndex: 1,
|
||||
formatter:
|
||||
@@ -456,8 +496,8 @@ export default function transformProps(
|
||||
formatter: seriesFormatter,
|
||||
})
|
||||
: seriesFormatter,
|
||||
totalStackedValues: sortedTotalValuesB,
|
||||
showValueIndexes: showValueIndexesB,
|
||||
totalStackedValues: totalStackedValuesB,
|
||||
thresholdValues: thresholdValuesB,
|
||||
timeShiftColor,
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
getNumberFormatter,
|
||||
getTimeFormatter,
|
||||
NumberFormatter,
|
||||
isDefined,
|
||||
} from '@superset-ui/core';
|
||||
import type { CallbackDataParams } from 'echarts/types/src/util/types';
|
||||
import type { RadarSeriesDataItemOption } from 'echarts/types/src/chart/radar/RadarSeries';
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
EchartsRadarFormData,
|
||||
EchartsRadarLabelType,
|
||||
RadarChartTransformedProps,
|
||||
SeriesNormalizedMap,
|
||||
} from './types';
|
||||
import { DEFAULT_LEGEND_FORM_DATA, OpacityEnum } from '../constants';
|
||||
import {
|
||||
@@ -46,18 +48,31 @@ import {
|
||||
import { defaultGrid } from '../defaults';
|
||||
import { Refs } from '../types';
|
||||
import { getDefaultTooltip } from '../utils/tooltip';
|
||||
import { findGlobalMax, renderNormalizedTooltip } from './utils';
|
||||
|
||||
export function formatLabel({
|
||||
params,
|
||||
labelType,
|
||||
numberFormatter,
|
||||
getDenormalizedSeriesValue,
|
||||
metricsWithCustomBounds,
|
||||
metricLabels,
|
||||
}: {
|
||||
params: CallbackDataParams;
|
||||
labelType: EchartsRadarLabelType;
|
||||
numberFormatter: NumberFormatter;
|
||||
getDenormalizedSeriesValue: (seriesName: string, value: string) => number;
|
||||
metricsWithCustomBounds: Set<string>;
|
||||
metricLabels: string[];
|
||||
}): string {
|
||||
const { name = '', value } = params;
|
||||
const formattedValue = numberFormatter(value as number);
|
||||
const { name = '', value, dimensionIndex = 0 } = params;
|
||||
const metricLabel = metricLabels[dimensionIndex];
|
||||
|
||||
const formattedValue = numberFormatter(
|
||||
metricsWithCustomBounds.has(metricLabel)
|
||||
? (value as number)
|
||||
: (getDenormalizedSeriesValue(name, String(value)) as number),
|
||||
);
|
||||
|
||||
switch (labelType) {
|
||||
case EchartsRadarLabelType.Value:
|
||||
@@ -85,6 +100,7 @@ export default function transformProps(
|
||||
} = chartProps;
|
||||
const refs: Refs = {};
|
||||
const { data = [] } = queriesData[0];
|
||||
const globalMax = findGlobalMax(data, Object.keys(data[0] || {}));
|
||||
const coltypeMapping = getColtypesMapping(queriesData[0]);
|
||||
|
||||
const {
|
||||
@@ -111,14 +127,38 @@ export default function transformProps(
|
||||
const { setDataMask = () => {}, onContextMenu } = hooks;
|
||||
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
|
||||
const numberFormatter = getNumberFormatter(numberFormat);
|
||||
const denormalizedSeriesValues: SeriesNormalizedMap = {};
|
||||
|
||||
const getDenormalizedSeriesValue = (
|
||||
seriesName: string,
|
||||
normalizedValue: string,
|
||||
): number =>
|
||||
denormalizedSeriesValues?.[seriesName]?.[normalizedValue] ??
|
||||
Number(normalizedValue);
|
||||
|
||||
const metricLabels = metrics.map(getMetricLabel);
|
||||
|
||||
const metricsWithCustomBounds = new Set(
|
||||
metricLabels.filter(metricLabel => {
|
||||
const config = columnConfig?.[metricLabel];
|
||||
const hasMax = !!isDefined(config?.radarMetricMaxValue);
|
||||
const hasMin =
|
||||
isDefined(config?.radarMetricMinValue) &&
|
||||
config?.radarMetricMinValue !== 0;
|
||||
return hasMax || hasMin;
|
||||
}),
|
||||
);
|
||||
|
||||
const formatter = (params: CallbackDataParams) =>
|
||||
formatLabel({
|
||||
params,
|
||||
numberFormatter,
|
||||
labelType,
|
||||
getDenormalizedSeriesValue,
|
||||
metricsWithCustomBounds,
|
||||
metricLabels,
|
||||
});
|
||||
|
||||
const metricLabels = metrics.map(getMetricLabel);
|
||||
const groupbyLabels = groupby.map(getColumnLabel);
|
||||
|
||||
const metricLabelAndMaxValueMap = new Map<string, number>();
|
||||
@@ -212,28 +252,58 @@ export default function transformProps(
|
||||
{},
|
||||
);
|
||||
|
||||
const normalizeArray = (arr: number[], decimals = 10, seriesName: string) =>
|
||||
arr.map((value, index) => {
|
||||
const metricLabel = metricLabels[index];
|
||||
if (metricsWithCustomBounds.has(metricLabel)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const max = Math.max(...arr);
|
||||
const normalizedValue = Number((value / max).toFixed(decimals));
|
||||
|
||||
denormalizedSeriesValues[seriesName][String(normalizedValue)] = value;
|
||||
return normalizedValue;
|
||||
});
|
||||
|
||||
// Normalize the transformed data
|
||||
const normalizedTransformedData = transformedData.map(series => {
|
||||
if (Array.isArray(series.value)) {
|
||||
const seriesName = String(series?.name || '');
|
||||
denormalizedSeriesValues[seriesName] = {};
|
||||
|
||||
return {
|
||||
...series,
|
||||
value: normalizeArray(series.value as number[], 10, seriesName),
|
||||
};
|
||||
}
|
||||
return series;
|
||||
});
|
||||
|
||||
const indicator = metricLabels.map(metricLabel => {
|
||||
const isMetricWithCustomBounds = metricsWithCustomBounds.has(metricLabel);
|
||||
if (!isMetricWithCustomBounds) {
|
||||
return {
|
||||
name: metricLabel,
|
||||
max: 1,
|
||||
min: 0,
|
||||
};
|
||||
}
|
||||
const maxValueInControl = columnConfig?.[metricLabel]?.radarMetricMaxValue;
|
||||
const minValueInControl = columnConfig?.[metricLabel]?.radarMetricMinValue;
|
||||
|
||||
// Ensure that 0 is at the center of the polar coordinates
|
||||
const metricValueAsMax =
|
||||
const maxValue =
|
||||
metricLabelAndMaxValueMap.get(metricLabel) === 0
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: metricLabelAndMaxValueMap.get(metricLabel);
|
||||
const max =
|
||||
maxValueInControl === null ? metricValueAsMax : maxValueInControl;
|
||||
: globalMax;
|
||||
const max = isDefined(maxValueInControl) ? maxValueInControl : maxValue;
|
||||
|
||||
let min: number;
|
||||
// If the min value doesn't exist, set it to 0 (default),
|
||||
// if it is null, set it to the min value of the data,
|
||||
// otherwise, use the value from the control
|
||||
if (minValueInControl === undefined) {
|
||||
min = 0;
|
||||
} else if (minValueInControl === null) {
|
||||
min = metricLabelAndMinValueMap.get(metricLabel) || 0;
|
||||
} else {
|
||||
if (isDefined(minValueInControl)) {
|
||||
min = minValueInControl;
|
||||
} else {
|
||||
min = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -255,10 +325,24 @@ export default function transformProps(
|
||||
backgroundColor: theme.colors.grayscale.light5,
|
||||
},
|
||||
},
|
||||
data: transformedData,
|
||||
data: normalizedTransformedData,
|
||||
},
|
||||
];
|
||||
|
||||
const NormalizedTooltipFormater = (
|
||||
params: CallbackDataParams & {
|
||||
color: string;
|
||||
name: string;
|
||||
value: number[];
|
||||
},
|
||||
) =>
|
||||
renderNormalizedTooltip(
|
||||
params,
|
||||
metricLabels,
|
||||
getDenormalizedSeriesValue,
|
||||
metricsWithCustomBounds,
|
||||
);
|
||||
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
grid: {
|
||||
...defaultGrid,
|
||||
@@ -267,6 +351,7 @@ export default function transformProps(
|
||||
...getDefaultTooltip(refs),
|
||||
show: !inContextMenu,
|
||||
trigger: 'item',
|
||||
formatter: NormalizedTooltipFormater,
|
||||
},
|
||||
legend: {
|
||||
...getLegendProps(legendType, legendOrientation, showLegend, theme),
|
||||
|
||||
@@ -35,7 +35,7 @@ import { DEFAULT_LEGEND_FORM_DATA } from '../constants';
|
||||
|
||||
type RadarColumnConfig = Record<
|
||||
string,
|
||||
{ radarMetricMaxValue?: number; radarMetricMinValue?: number }
|
||||
{ radarMetricMaxValue?: number | null; radarMetricMinValue?: number }
|
||||
>;
|
||||
|
||||
export type EchartsRadarFormData = QueryFormData &
|
||||
@@ -53,6 +53,7 @@ export type EchartsRadarFormData = QueryFormData &
|
||||
isCircle: boolean;
|
||||
numberFormat: string;
|
||||
dateFormat: string;
|
||||
isNormalized: boolean;
|
||||
};
|
||||
|
||||
export enum EchartsRadarLabelType {
|
||||
@@ -83,3 +84,17 @@ export type RadarChartTransformedProps =
|
||||
BaseTransformedProps<EchartsRadarFormData> &
|
||||
ContextMenuTransformedProps &
|
||||
CrossFilterTransformedProps;
|
||||
|
||||
/**
|
||||
* Represents a mapping from a normalized value (as string) to an original numeric value.
|
||||
*/
|
||||
interface NormalizedValueMap {
|
||||
[normalized: string]: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a collection of series, each containing its own NormalizedValueMap.
|
||||
*/
|
||||
export interface SeriesNormalizedMap {
|
||||
[seriesName: string]: NormalizedValueMap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
/*
|
||||
function for finding the max metric values among all series data for Radar Chart
|
||||
*/
|
||||
export const findGlobalMax = (
|
||||
data: Record<string, unknown>[],
|
||||
metrics: string[],
|
||||
): number => {
|
||||
if (!data?.length || !metrics?.length) return 0;
|
||||
|
||||
return data.reduce((globalMax, row) => {
|
||||
const rowMax = metrics.reduce((max, metric) => {
|
||||
const value = row[metric];
|
||||
return typeof value === 'number' &&
|
||||
Number.isFinite(value) &&
|
||||
!Number.isNaN(value)
|
||||
? Math.max(max, value)
|
||||
: max;
|
||||
}, 0);
|
||||
|
||||
return Math.max(globalMax, rowMax);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
interface TooltipParams {
|
||||
color: string;
|
||||
name?: string;
|
||||
value: number[];
|
||||
}
|
||||
|
||||
interface TooltipMetricValue {
|
||||
metric: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export const renderNormalizedTooltip = (
|
||||
params: TooltipParams,
|
||||
metrics: string[],
|
||||
getDenormalizedValue: (seriesName: string, value: string) => number,
|
||||
metricsWithCustomBounds: Set<string>,
|
||||
): string => {
|
||||
const { color, name = '', value: values } = params;
|
||||
const seriesName = name || 'series0';
|
||||
|
||||
const colorDot = `<span style="display:inline-block;margin-right:5px;border-radius:50%;width:5px;height:5px;background-color:${color}"></span>`;
|
||||
|
||||
// Get metric values with denormalization if needed
|
||||
const metricValues: TooltipMetricValue[] = metrics.map((metric, index) => {
|
||||
const value = values[index];
|
||||
const originalValue = metricsWithCustomBounds.has(metric)
|
||||
? value
|
||||
: getDenormalizedValue(name, String(value));
|
||||
|
||||
return {
|
||||
metric,
|
||||
value: originalValue,
|
||||
};
|
||||
});
|
||||
|
||||
const tooltipRows = metricValues
|
||||
.map(
|
||||
({ metric, value }) => `
|
||||
<div style="display:flex;">
|
||||
<div>${colorDot}${metric}:</div>
|
||||
<div style="font-weight:bold;margin-left:auto;">${value}</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div style="font-weight:bold;margin-bottom:5px;">${seriesName}</div>
|
||||
${tooltipRows}
|
||||
`;
|
||||
};
|
||||
@@ -73,13 +73,25 @@ export default function transformProps(
|
||||
}));
|
||||
|
||||
// stores a map with the total values for each node considering the links
|
||||
const nodeValues = new Map<string, number>();
|
||||
const incomingFlows = new Map<string, number>();
|
||||
const outgoingFlows = new Map<string, number>();
|
||||
const allNodeNames = new Set<string>();
|
||||
|
||||
links.forEach(link => {
|
||||
const { source, target, value } = link;
|
||||
const sourceValue = nodeValues.get(source) || 0;
|
||||
const targetValue = nodeValues.get(target) || 0;
|
||||
nodeValues.set(source, sourceValue + value);
|
||||
nodeValues.set(target, targetValue + value);
|
||||
allNodeNames.add(source);
|
||||
allNodeNames.add(target);
|
||||
incomingFlows.set(target, (incomingFlows.get(target) || 0) + value);
|
||||
outgoingFlows.set(source, (outgoingFlows.get(source) || 0) + value);
|
||||
});
|
||||
|
||||
const nodeValues = new Map<string, number>();
|
||||
|
||||
allNodeNames.forEach(nodeName => {
|
||||
const totalIncoming = incomingFlows.get(nodeName) || 0;
|
||||
const totalOutgoing = outgoingFlows.get(nodeName) || 0;
|
||||
|
||||
nodeValues.set(nodeName, Math.max(totalIncoming, totalOutgoing));
|
||||
});
|
||||
|
||||
const tooltipFormatter = (params: CallbackDataParams) => {
|
||||
|
||||
@@ -65,6 +65,8 @@ export default function EchartsTimeseries({
|
||||
const clickTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const extraControlRef = useRef<HTMLDivElement>(null);
|
||||
const [extraControlHeight, setExtraControlHeight] = useState(0);
|
||||
// Track the current legend page index
|
||||
const [currentLegendPage, setCurrentLegendPage] = useState(0);
|
||||
useEffect(() => {
|
||||
const updatedHeight = extraControlRef.current?.offsetHeight || 0;
|
||||
setExtraControlHeight(updatedHeight);
|
||||
@@ -139,6 +141,20 @@ export default function EchartsTimeseries({
|
||||
[emitCrossFilters, setDataMask, getCrossFilterDataMask],
|
||||
);
|
||||
|
||||
// Helper function to restore legend page position
|
||||
const restoreLegendPagePosition = useCallback(() => {
|
||||
// Use requestAnimationFrame instead of setTimeout for better alignment with the browser's rendering cycle
|
||||
requestAnimationFrame(() => {
|
||||
const echartInstance = echartRef.current?.getEchartInstance();
|
||||
if (echartInstance) {
|
||||
echartInstance.dispatchAction({
|
||||
type: 'legendScroll',
|
||||
scrollDataIndex: currentLegendPage,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [currentLegendPage]);
|
||||
|
||||
const eventHandlers: EventHandlers = {
|
||||
click: props => {
|
||||
if (!hasDimensions) {
|
||||
@@ -161,12 +177,21 @@ export default function EchartsTimeseries({
|
||||
},
|
||||
legendselectchanged: payload => {
|
||||
onLegendStateChanged?.(payload.selected);
|
||||
restoreLegendPagePosition();
|
||||
},
|
||||
legendselectall: payload => {
|
||||
onLegendStateChanged?.(payload.selected);
|
||||
restoreLegendPagePosition();
|
||||
},
|
||||
legendinverseselect: payload => {
|
||||
onLegendStateChanged?.(payload.selected);
|
||||
restoreLegendPagePosition();
|
||||
},
|
||||
// Add handler for legend scroll event to track current page
|
||||
legendscroll: params => {
|
||||
if (params.scrollDataIndex !== undefined) {
|
||||
setCurrentLegendPage(params.scrollDataIndex);
|
||||
}
|
||||
},
|
||||
contextmenu: async eventParams => {
|
||||
if (onContextMenu) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { QueryFormMetric } from '@superset-ui/core';
|
||||
|
||||
export const getMetricDisplayName = (
|
||||
metric: QueryFormMetric,
|
||||
verboseMap: Record<string, string> = {},
|
||||
): string => {
|
||||
// Case 1: Simple string metric - use verboseMap or the string itself
|
||||
if (typeof metric === 'string') {
|
||||
return verboseMap[metric] || metric;
|
||||
}
|
||||
|
||||
// Case 2: Metric with explicit label - always prefer this if available
|
||||
if (metric.label) {
|
||||
return metric.label;
|
||||
}
|
||||
|
||||
// Case 3: SIMPLE expression type (column with aggregate)
|
||||
if (metric.expressionType === 'SIMPLE') {
|
||||
const column = metric.column || {};
|
||||
const columnName = column.column_name || '';
|
||||
// Use verbose name from column if available
|
||||
const displayName = column.verbose_name || columnName;
|
||||
const aggregate = metric.aggregate || '';
|
||||
|
||||
// If the verbose map has this column, use that
|
||||
if (verboseMap[columnName]) {
|
||||
return `${aggregate}(${verboseMap[columnName]})`;
|
||||
}
|
||||
|
||||
return `${aggregate}(${displayName})`;
|
||||
}
|
||||
|
||||
// Case 4: SQL expression
|
||||
if (metric.expressionType === 'SQL') {
|
||||
return metric.sqlExpression || 'Custom SQL Metric';
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return 'Unknown Metric';
|
||||
};
|
||||
@@ -118,7 +118,9 @@ const chartPropsConfig = {
|
||||
|
||||
it('should transform chart props for viz', () => {
|
||||
const chartProps = new ChartProps(chartPropsConfig);
|
||||
expect(transformProps(chartProps as EchartsMixedTimeseriesProps)).toEqual(
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
|
||||
expect(transformed).toEqual(
|
||||
expect.objectContaining({
|
||||
echartOptions: expect.objectContaining({
|
||||
series: expect.arrayContaining([
|
||||
@@ -127,7 +129,7 @@ it('should transform chart props for viz', () => {
|
||||
[599616000000, 1],
|
||||
[599916000000, 3],
|
||||
],
|
||||
id: 'boy',
|
||||
id: 'sum__num (Query A), boy',
|
||||
stack: 'obs\na',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
@@ -135,15 +137,16 @@ it('should transform chart props for viz', () => {
|
||||
[599616000000, 2],
|
||||
[599916000000, 4],
|
||||
],
|
||||
id: 'girl',
|
||||
id: 'sum__num (Query A), girl',
|
||||
stack: 'obs\na',
|
||||
}),
|
||||
// Query B — Bar series
|
||||
expect.objectContaining({
|
||||
data: [
|
||||
[599616000000, 1],
|
||||
[599916000000, 3],
|
||||
],
|
||||
id: 'boy (1)',
|
||||
id: 'sum__num (Query B), boy',
|
||||
stack: 'obs\nb',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
@@ -151,7 +154,7 @@ it('should transform chart props for viz', () => {
|
||||
[599616000000, 2],
|
||||
[599916000000, 4],
|
||||
],
|
||||
id: 'girl (1)',
|
||||
id: 'sum__num (Query B), girl',
|
||||
stack: 'obs\nb',
|
||||
}),
|
||||
]),
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartProps, supersetTheme } from '@superset-ui/core';
|
||||
import { RadarSeriesOption } from 'echarts/charts';
|
||||
import transformProps from '../../src/Radar/transformProps';
|
||||
import {
|
||||
EchartsRadarChartProps,
|
||||
EchartsRadarFormData,
|
||||
} from '../../src/Radar/types';
|
||||
|
||||
interface RadarIndicator {
|
||||
name: string;
|
||||
max: number;
|
||||
min: number;
|
||||
}
|
||||
|
||||
type RadarShape = 'circle' | 'polygon';
|
||||
|
||||
interface RadarChartConfig {
|
||||
shape: RadarShape;
|
||||
indicator: RadarIndicator[];
|
||||
}
|
||||
|
||||
interface RadarSeriesData {
|
||||
value: number[];
|
||||
name: string;
|
||||
}
|
||||
|
||||
describe('Radar transformProps', () => {
|
||||
const formData: Partial<EchartsRadarFormData> = {
|
||||
colorScheme: 'supersetColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: 'ds',
|
||||
columnConfig: {
|
||||
'MAX(na_sales)': {
|
||||
radarMetricMaxValue: null,
|
||||
radarMetricMinValue: 0,
|
||||
},
|
||||
'SUM(eu_sales)': {
|
||||
radarMetricMaxValue: 5000,
|
||||
},
|
||||
},
|
||||
groupby: [],
|
||||
metrics: [
|
||||
'MAX(na_sales)',
|
||||
'SUM(jp_sales)',
|
||||
'SUM(other_sales)',
|
||||
'SUM(eu_sales)',
|
||||
],
|
||||
viz_type: 'radar',
|
||||
numberFormat: 'SMART_NUMBER',
|
||||
dateFormat: 'smart_date',
|
||||
showLegend: true,
|
||||
showLabels: true,
|
||||
isCircle: false,
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
formData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
'MAX(na_sales)': 41.49,
|
||||
'SUM(jp_sales)': 1290.99,
|
||||
'SUM(other_sales)': 797.73,
|
||||
'SUM(eu_sales)': 2434.13,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
it('should transform chart props for normalized radar chart & normalize all metrics except the ones with custom min & max', () => {
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsRadarChartProps,
|
||||
);
|
||||
const series = transformedProps.echartOptions.series as RadarSeriesOption[];
|
||||
const radar = transformedProps.echartOptions.radar as RadarChartConfig;
|
||||
|
||||
expect((series[0].data as RadarSeriesData[])[0].value).toEqual([
|
||||
0.0170451044, 0.5303701939, 0.3277269497, 2434.13,
|
||||
]);
|
||||
|
||||
expect(radar.indicator).toEqual([
|
||||
{
|
||||
name: 'MAX(na_sales)',
|
||||
max: 1,
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'SUM(jp_sales)',
|
||||
max: 1,
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'SUM(other_sales)',
|
||||
max: 1,
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'SUM(eu_sales)',
|
||||
max: 5000,
|
||||
min: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,7 @@ module.exports = async results => {
|
||||
// Ignore thie rule here - the messages in eslintrc_metrics.js are sufficient descriptions, broken down by file type.
|
||||
'no-restricted-imports': {
|
||||
description:
|
||||
"This rule catches several things that shouldn't be used anymore. LESS, antD, enzyme, etc. See individual occurrence messages for details",
|
||||
"This rule catches several things that shouldn't be used anymore. LESS, antD, etc. See individual occurrence messages for details",
|
||||
},
|
||||
'no-console': {
|
||||
description:
|
||||
|
||||
@@ -20,10 +20,7 @@ import { AriaAttributes } from 'react';
|
||||
import 'core-js/stable';
|
||||
import 'regenerator-runtime/runtime';
|
||||
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only';
|
||||
import 'enzyme-matchers';
|
||||
import jQuery from 'jquery';
|
||||
import Enzyme from 'enzyme';
|
||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
// https://jestjs.io/docs/jest-object#jestmockmodulename-factory-options
|
||||
// in order to mock modules in test case, so avoid absolute import module
|
||||
import { configure as configureTranslation } from '../../packages/superset-ui-core/src/translation';
|
||||
@@ -33,8 +30,6 @@ import { ResizeObserver } from './ResizeObserver';
|
||||
import setupSupersetClient from './setupSupersetClient';
|
||||
import CacheStorage from './CacheStorage';
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
|
||||
const exposedProperties = ['window', 'navigator', 'document'];
|
||||
|
||||
const { defaultView } = document;
|
||||
|
||||
@@ -1294,7 +1294,8 @@ export function createDatasourceFailed(err) {
|
||||
export function createDatasource(vizOptions) {
|
||||
return dispatch => {
|
||||
dispatch(createDatasourceStarted());
|
||||
const { dbId, catalog, schema, datasourceName, sql } = vizOptions;
|
||||
const { dbId, catalog, schema, datasourceName, sql, templateParams } =
|
||||
vizOptions;
|
||||
return SupersetClient.post({
|
||||
endpoint: '/api/v1/dataset/',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1306,6 +1307,7 @@ export function createDatasource(vizOptions) {
|
||||
table_name: datasourceName,
|
||||
is_managed_externally: false,
|
||||
external_url: null,
|
||||
template_params: templateParams,
|
||||
}),
|
||||
})
|
||||
.then(({ json }) => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import fetchMock from 'fetch-mock';
|
||||
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
|
||||
import { user, testQuery, mockdatasets } from 'src/SqlLab/fixtures';
|
||||
import { FeatureFlag } from '@superset-ui/core';
|
||||
|
||||
const mockedProps = {
|
||||
visible: true,
|
||||
@@ -250,4 +251,88 @@ describe('SaveDatasetModal', () => {
|
||||
templateParams: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not renders a checkbox button when template processing is disabled', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a checkbox button when template processing is enabled', () => {
|
||||
// @ts-ignore
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
expect(screen.getByRole('checkbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('correctly includes template parameters when template processing is enabled', () => {
|
||||
// @ts-ignore
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12 }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
|
||||
userEvent.click(screen.getByRole('checkbox'));
|
||||
|
||||
const saveConfirmationBtn = screen.getByRole('button', {
|
||||
name: /save/i,
|
||||
});
|
||||
userEvent.click(saveConfirmationBtn);
|
||||
|
||||
expect(createDatasource).toHaveBeenCalledWith({
|
||||
datasourceName: 'my dataset',
|
||||
dbId: 1,
|
||||
catalog: null,
|
||||
schema: 'main',
|
||||
sql: 'SELECT *',
|
||||
templateParams: JSON.stringify({ my_param: 12 }),
|
||||
});
|
||||
});
|
||||
|
||||
it('correctly excludes template parameters when template processing is enabled', () => {
|
||||
// @ts-ignore
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12 }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
|
||||
userEvent.click(screen.getByRole('checkbox'));
|
||||
|
||||
const saveConfirmationBtn = screen.getByRole('button', {
|
||||
name: /save/i,
|
||||
});
|
||||
userEvent.click(saveConfirmationBtn);
|
||||
|
||||
expect(createDatasource).toHaveBeenCalledWith({
|
||||
datasourceName: 'my dataset',
|
||||
dbId: 1,
|
||||
catalog: null,
|
||||
schema: 'main',
|
||||
sql: 'SELECT *',
|
||||
templateParams: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import { AsyncSelect } from 'src/components';
|
||||
import { Input } from 'src/components/Input';
|
||||
import StyledModal from 'src/components/Modal';
|
||||
import Button from 'src/components/Button';
|
||||
import Checkbox from 'src/components/Checkbox';
|
||||
import {
|
||||
styled,
|
||||
t,
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
QueryResponse,
|
||||
QueryFormData,
|
||||
VizType,
|
||||
FeatureFlag,
|
||||
isFeatureEnabled,
|
||||
} from '@superset-ui/core';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -185,6 +188,8 @@ export const SaveDatasetModal = ({
|
||||
|
||||
const user = useSelector<SqlLabRootState, User>(state => state.user);
|
||||
const dispatch = useDispatch<(dispatch: any) => Promise<JsonObject>>();
|
||||
const [includeTemplateParameters, setIncludeTemplateParameters] =
|
||||
useState(false);
|
||||
|
||||
const createWindow = (url: string) => {
|
||||
if (openWindow) {
|
||||
@@ -285,14 +290,21 @@ export const SaveDatasetModal = ({
|
||||
// Remove the special filters entry from the templateParams
|
||||
// before saving the dataset.
|
||||
let templateParams;
|
||||
if (typeof datasource?.templateParams === 'string') {
|
||||
const p = JSON.parse(datasource.templateParams);
|
||||
/* eslint-disable-next-line no-underscore-dangle */
|
||||
if (p._filters) {
|
||||
if (
|
||||
typeof datasource?.templateParams === 'string' &&
|
||||
includeTemplateParameters
|
||||
) {
|
||||
try {
|
||||
const p = JSON.parse(datasource.templateParams);
|
||||
/* eslint-disable-next-line no-underscore-dangle */
|
||||
delete p._filters;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (p._filters) {
|
||||
/* eslint-disable-next-line no-underscore-dangle */
|
||||
delete p._filters;
|
||||
}
|
||||
templateParams = JSON.stringify(p);
|
||||
} catch (e) {
|
||||
// malformed templateParams, do not include it
|
||||
templateParams = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +374,27 @@ export const SaveDatasetModal = ({
|
||||
title={t('Save or Overwrite Dataset')}
|
||||
onHide={onHide}
|
||||
footer={
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{isFeatureEnabled(FeatureFlag.EnableTemplateProcessing) && (
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Checkbox
|
||||
checked={includeTemplateParameters}
|
||||
onChange={checked =>
|
||||
setIncludeTemplateParameters(checked ?? false)
|
||||
}
|
||||
/>
|
||||
<span style={{ marginLeft: '5px' }}>
|
||||
{t('Include Template Parameters')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{newOrOverwrite === DatasetRadioState.SaveNew && (
|
||||
<Button
|
||||
disabled={disableSaveAndExploreBtn}
|
||||
@@ -389,7 +421,7 @@ export const SaveDatasetModal = ({
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Styles>
|
||||
|
||||
@@ -115,6 +115,7 @@ import {
|
||||
LOG_ACTIONS_SQLLAB_STOP_QUERY,
|
||||
Logger,
|
||||
} from 'src/logger/LogUtils';
|
||||
import CopyToClipboard from 'src/components/CopyToClipboard';
|
||||
import TemplateParamsEditor from '../TemplateParamsEditor';
|
||||
import SouthPane from '../SouthPane';
|
||||
import SaveQuery, { QueryPayload } from '../SaveQuery';
|
||||
@@ -315,6 +316,7 @@ const SqlEditor: FC<Props> = ({
|
||||
);
|
||||
const [showCreateAsModal, setShowCreateAsModal] = useState(false);
|
||||
const [createAs, setCreateAs] = useState('');
|
||||
const currentSQL = useRef<string>(queryEditor.sql);
|
||||
const showEmptyState = useMemo(
|
||||
() => !database || isEmpty(database),
|
||||
[database],
|
||||
@@ -648,6 +650,7 @@ const SqlEditor: FC<Props> = ({
|
||||
);
|
||||
|
||||
const onSqlChanged = useEffectEvent((sql: string) => {
|
||||
currentSQL.current = sql;
|
||||
dispatch(queryEditorSetSql(queryEditor, sql));
|
||||
});
|
||||
|
||||
@@ -890,6 +893,73 @@ const SqlEditor: FC<Props> = ({
|
||||
dispatch(queryEditorSetCursorPosition(queryEditor, newPosition));
|
||||
};
|
||||
|
||||
const copyQuery = (callback: (text: string) => void) => {
|
||||
callback(currentSQL.current);
|
||||
};
|
||||
const renderCopyQueryButton = () => (
|
||||
<Button type="primary">{t('COPY QUERY')}</Button>
|
||||
);
|
||||
|
||||
const renderDatasetWarning = () => (
|
||||
<Alert
|
||||
css={css`
|
||||
margin-bottom: ${theme.gridUnit * 2}px;
|
||||
padding-top: ${theme.gridUnit * 4}px;
|
||||
.antd5-alert-action {
|
||||
align-self: center;
|
||||
}
|
||||
`}
|
||||
type="info"
|
||||
action={
|
||||
<CopyToClipboard
|
||||
wrapText={false}
|
||||
copyNode={renderCopyQueryButton()}
|
||||
getText={copyQuery}
|
||||
/>
|
||||
}
|
||||
description={
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`}
|
||||
>
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`}
|
||||
>
|
||||
<p
|
||||
css={css`
|
||||
font-size: ${theme.typography.sizes.m}px;
|
||||
font-weight: ${theme.typography.weights.medium};
|
||||
color: ${theme.colors.primary.dark2};
|
||||
`}
|
||||
>
|
||||
{' '}
|
||||
{t(`You are edting a query from the virtual dataset `) +
|
||||
queryEditor.name}
|
||||
</p>
|
||||
<p
|
||||
css={css`
|
||||
font-size: ${theme.typography.sizes.m}px;
|
||||
font-weight: ${theme.typography.weights.normal};
|
||||
color: ${theme.colors.primary.dark2};
|
||||
`}
|
||||
>
|
||||
{t(
|
||||
'After making the changes, copy the query and paste in the virtual dataset SQL snippet settings.',
|
||||
)}{' '}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
message=""
|
||||
/>
|
||||
);
|
||||
|
||||
const queryPane = () => {
|
||||
const { aceEditorHeight, southPaneHeight } =
|
||||
getAceEditorAndSouthPaneHeights(height, northPercent, southPercent);
|
||||
@@ -899,7 +969,7 @@ const SqlEditor: FC<Props> = ({
|
||||
className="queryPane"
|
||||
sizes={[northPercent, southPercent]}
|
||||
elementStyle={elementStyle}
|
||||
minSize={200}
|
||||
minSize={queryEditor.isDataset ? 400 : 200}
|
||||
direction="vertical"
|
||||
gutterSize={SQL_EDITOR_GUTTER_HEIGHT}
|
||||
onDragStart={onResizeStart}
|
||||
@@ -915,6 +985,7 @@ const SqlEditor: FC<Props> = ({
|
||||
startQuery={startQuery}
|
||||
/>
|
||||
)}
|
||||
{queryEditor.isDataset && renderDatasetWarning()}
|
||||
{isActive && (
|
||||
<AceEditorWrapper
|
||||
autocomplete={autocompleteEnabled && !isTempId(queryEditor.id)}
|
||||
|
||||
@@ -141,6 +141,7 @@ class TabbedSqlEditors extends PureComponent<TabbedSqlEditorsProps> {
|
||||
schema,
|
||||
autorun,
|
||||
sql,
|
||||
isDataset: this.context.isDataset,
|
||||
};
|
||||
this.props.actions.addQueryEditor(newQueryEditor);
|
||||
}
|
||||
@@ -229,7 +230,6 @@ class TabbedSqlEditors extends PureComponent<TabbedSqlEditorsProps> {
|
||||
<EditableTabs.TabPane
|
||||
key={qe.id}
|
||||
tab={<SqlEditorTabHeader queryEditor={qe} />}
|
||||
// for tests - key prop isn't handled by enzyme well bcs it's a react keyword
|
||||
data-key={qe.id}
|
||||
>
|
||||
<SqlEditor
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface QueryEditor {
|
||||
southPercent?: number;
|
||||
updatedAt?: number;
|
||||
cursorPosition?: CursorPosition;
|
||||
isDataset?: boolean;
|
||||
}
|
||||
|
||||
export type toastState = {
|
||||
|
||||
@@ -149,14 +149,6 @@ const StyledButtonWrapper = styled.span`
|
||||
`}
|
||||
`;
|
||||
|
||||
const sqlTooltipOptions = {
|
||||
placement: 'topRight',
|
||||
title: t(
|
||||
'If changes are made to your SQL query, ' +
|
||||
'columns in your dataset will be synced when saving the dataset.',
|
||||
),
|
||||
};
|
||||
|
||||
const checkboxGenerator = (d, onChange) => (
|
||||
<CheckboxControl value={d} onChange={onChange} />
|
||||
);
|
||||
@@ -723,6 +715,22 @@ class DatasourceEditor extends PureComponent {
|
||||
});
|
||||
}
|
||||
|
||||
getSQLLabUrl() {
|
||||
const queryParams = new URLSearchParams({
|
||||
dbid: this.state.datasource.database.id,
|
||||
sql: this.state.datasource.sql,
|
||||
name: this.state.datasource.datasource_name,
|
||||
schema: this.state.datasource.schema,
|
||||
autorun: true,
|
||||
isDataset: true,
|
||||
});
|
||||
return `/sqllab/?${queryParams.toString()}`;
|
||||
}
|
||||
|
||||
openOnSqlLab() {
|
||||
window.open(this.getSQLLabUrl(), '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
tableChangeAndSyncMetadata() {
|
||||
this.validate(() => {
|
||||
this.syncMetadata();
|
||||
@@ -996,8 +1004,81 @@ class DatasourceEditor extends PureComponent {
|
||||
);
|
||||
}
|
||||
|
||||
renderSqlEditorOverlay = () => (
|
||||
<div
|
||||
css={theme => css`
|
||||
position: absolute;
|
||||
background: ${theme.colors.secondary.light5};
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
`}
|
||||
>
|
||||
<div>
|
||||
<Loading position="inline-centered" />
|
||||
<span
|
||||
css={theme => css`
|
||||
display: block;
|
||||
margin: ${theme.gridUnit * 4}px auto;
|
||||
width: fit-content;
|
||||
color: ${theme.colors.grayscale.base};
|
||||
`}
|
||||
>
|
||||
{t('We are working on your query')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
renderOpenInSqlLabLink(isError = false) {
|
||||
return (
|
||||
<a
|
||||
href={this.getSQLLabUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
css={theme => css`
|
||||
color: ${isError
|
||||
? theme.colors.error.base
|
||||
: theme.colors.grayscale.base};
|
||||
font-size: ${theme.typography.sizes.s}px;
|
||||
text-decoration: underline;
|
||||
`}
|
||||
>
|
||||
{t('Open in SQL lab')}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
renderSqlErrorMessage = () => (
|
||||
<>
|
||||
<span
|
||||
css={theme => css`
|
||||
font-size: ${theme.typography.sizes.s}px;
|
||||
`}
|
||||
>
|
||||
{this.props.database?.error && t('Error executing query. ')}
|
||||
</span>
|
||||
{this.renderOpenInSqlLabLink(true)}
|
||||
<span
|
||||
css={theme => css`
|
||||
font-size: ${theme.typography.sizes.s}px;
|
||||
`}
|
||||
>
|
||||
{t(' to check for details.')}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
renderSourceFieldset() {
|
||||
const { datasource } = this.state;
|
||||
const floatingButtonCss = css`
|
||||
align-self: flex-end;
|
||||
height: 24px;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
`;
|
||||
return (
|
||||
<div>
|
||||
<EditLockContainer>
|
||||
@@ -1097,18 +1178,33 @@ class DatasourceEditor extends PureComponent {
|
||||
description={t(
|
||||
'When specifying SQL, the datasource acts as a view. ' +
|
||||
'Superset will use this statement as a subquery while grouping and filtering ' +
|
||||
'on the generated parent queries.',
|
||||
'on the generated parent queries.' +
|
||||
'If changes are made to your SQL query, ' +
|
||||
'columns in your dataset will be synced when saving the dataset.',
|
||||
)}
|
||||
control={
|
||||
<TextAreaControl
|
||||
language="sql"
|
||||
offerEditInModal={false}
|
||||
minLines={10}
|
||||
maxLines={Infinity}
|
||||
readOnly={!this.state.isEditMode}
|
||||
resize="both"
|
||||
tooltipOptions={sqlTooltipOptions}
|
||||
/>
|
||||
this.props.database?.isLoading ? (
|
||||
<>
|
||||
{this.renderSqlEditorOverlay()}
|
||||
<TextAreaControl
|
||||
language="sql"
|
||||
offerEditInModal={false}
|
||||
minLines={10}
|
||||
maxLines={Infinity}
|
||||
readOnly={!this.state.isEditMode}
|
||||
resize="both"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TextAreaControl
|
||||
language="sql"
|
||||
offerEditInModal={false}
|
||||
minLines={10}
|
||||
maxLines={Infinity}
|
||||
readOnly={!this.state.isEditMode}
|
||||
resize="both"
|
||||
/>
|
||||
)
|
||||
}
|
||||
additionalControl={
|
||||
<div
|
||||
@@ -1120,12 +1216,25 @@ class DatasourceEditor extends PureComponent {
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
css={css`
|
||||
align-self: flex-end;
|
||||
height: 24px;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
`}
|
||||
disabled={this.props.database?.isLoading}
|
||||
tooltip={t('Open SQL Lab in a new tab')}
|
||||
css={floatingButtonCss}
|
||||
size="small"
|
||||
>
|
||||
<Icons.ExportOutlined
|
||||
iconSize="s"
|
||||
css={theme => ({
|
||||
color: theme.colors.primary.dark1,
|
||||
})}
|
||||
onClick={() => {
|
||||
this.openOnSqlLab();
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={this.props.database?.isLoading}
|
||||
tooltip={t('Run query')}
|
||||
css={floatingButtonCss}
|
||||
size="small"
|
||||
buttonStyle="primary"
|
||||
onClick={() => {
|
||||
@@ -1142,22 +1251,49 @@ class DatasourceEditor extends PureComponent {
|
||||
</div>
|
||||
}
|
||||
errorMessage={
|
||||
this.props.database?.error && t('Error executing query.')
|
||||
this.props.database?.error && this.renderSqlErrorMessage()
|
||||
}
|
||||
/>
|
||||
{this.props.database?.queryResult && (
|
||||
<ResultTable
|
||||
data={this.props.database.queryResult.data}
|
||||
queryId={this.props.database.queryResult.query.id}
|
||||
orderedColumnKeys={this.props.database.queryResult.columns.map(
|
||||
col => col.column_name,
|
||||
)}
|
||||
height={100}
|
||||
expandedColumns={
|
||||
this.props.database.queryResult.expandedColumns
|
||||
}
|
||||
allowHTML
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
css={theme => css`
|
||||
margin-bottom: ${theme.gridUnit * 4}px;
|
||||
`}
|
||||
>
|
||||
<span
|
||||
css={theme => css`
|
||||
color: ${theme.colors.grayscale.base};
|
||||
font-size: ${theme.typography.sizes.s}px;
|
||||
`}
|
||||
>
|
||||
{t(
|
||||
'In this view you can preview the first 25 rows. ',
|
||||
)}
|
||||
</span>
|
||||
{this.renderOpenInSqlLabLink()}
|
||||
<span
|
||||
css={theme => css`
|
||||
color: ${theme.colors.grayscale.base};
|
||||
font-size: ${theme.typography.sizes.s}px;
|
||||
`}
|
||||
>
|
||||
{t(' to see details.')}
|
||||
</span>
|
||||
</div>
|
||||
<ResultTable
|
||||
data={this.props.database?.queryResult.data}
|
||||
queryId={this.props.database?.queryResult.query.id}
|
||||
orderedColumnKeys={this.props.database?.queryResult.columns.map(
|
||||
col => col.column_name,
|
||||
)}
|
||||
height={100}
|
||||
expandedColumns={
|
||||
this.props.database?.queryResult.expandedColumns
|
||||
}
|
||||
allowHTML
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -1555,8 +1691,7 @@ const mapDispatchToProps = dispatch => ({
|
||||
resetQuery: () => dispatch(resetDatabaseState()),
|
||||
});
|
||||
const mapStateToProps = state => ({
|
||||
test: state.queryApi,
|
||||
database: state.database,
|
||||
database: state?.database,
|
||||
});
|
||||
export default withToasts(
|
||||
connect(mapStateToProps, mapDispatchToProps)(DataSourceComponent),
|
||||
|
||||
@@ -45,12 +45,17 @@ const props = {
|
||||
},
|
||||
};
|
||||
const DATASOURCE_ENDPOINT = 'glob:*/datasource/external_metadata_by_name/*';
|
||||
|
||||
const routeProps = {
|
||||
history: {},
|
||||
location: {},
|
||||
match: {},
|
||||
};
|
||||
const asyncRender = props =>
|
||||
waitFor(() =>
|
||||
render(<DatasourceEditor {...props} />, {
|
||||
render(<DatasourceEditor {...props} {...routeProps} />, {
|
||||
useRedux: true,
|
||||
initialState: { common: { currencies: ['USD', 'GBP', 'EUR'] } },
|
||||
useRouter: true,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ import mockDatasource from 'spec/fixtures/mockDatasource';
|
||||
// Define your constants here
|
||||
const SAVE_ENDPOINT = 'glob:*/api/v1/dataset/7';
|
||||
const SAVE_PAYLOAD = { new: 'data' };
|
||||
const SAVE_DATASOURCE_ENDPOINT = 'glob:*/api/v1/dataset/7';
|
||||
const GET_DATASOURCE_ENDPOINT = SAVE_DATASOURCE_ENDPOINT;
|
||||
const SAVE_DATASOURCE_ENDPOINT = 'glob:*/api/v1/dataset/7?override_columns=*';
|
||||
const GET_DATASOURCE_ENDPOINT = 'glob:*/api/v1/dataset/7';
|
||||
const GET_DATABASE_ENDPOINT = 'glob:*/api/v1/database/?q=*';
|
||||
|
||||
const mockedProps = {
|
||||
@@ -49,11 +49,15 @@ const mockedProps = {
|
||||
};
|
||||
|
||||
let container;
|
||||
|
||||
const routeProps = {
|
||||
history: {},
|
||||
location: {},
|
||||
match: {},
|
||||
};
|
||||
async function renderAndWait(props = mockedProps) {
|
||||
const { container: renderedContainer } = render(
|
||||
<DatasourceModal {...props} />,
|
||||
{ store },
|
||||
<DatasourceModal {...props} {...routeProps} />,
|
||||
{ store, useRouter: true },
|
||||
);
|
||||
|
||||
container = renderedContainer;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { FunctionComponent, useState, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Alert from 'src/components/Alert';
|
||||
import Button from 'src/components/Button';
|
||||
import {
|
||||
@@ -36,15 +36,6 @@ import Modal from 'src/components/Modal';
|
||||
import AsyncEsmComponent from 'src/components/AsyncEsmComponent';
|
||||
import ErrorMessageWithStackTrace from 'src/components/ErrorMessage/ErrorMessageWithStackTrace';
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import {
|
||||
startMetaDataLoading,
|
||||
stopMetaDataLoading,
|
||||
syncDatasourceMetadata,
|
||||
} from 'src/explore/actions/exploreActions';
|
||||
import {
|
||||
fetchSyncedColumns,
|
||||
updateColumns,
|
||||
} from 'src/components/Datasource/utils';
|
||||
import { DatasetObject } from '../../features/datasets/types';
|
||||
|
||||
const DatasourceEditor = AsyncEsmComponent(() => import('./DatasourceEditor'));
|
||||
@@ -98,14 +89,12 @@ function buildExtraJsonObject(
|
||||
|
||||
const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
datasource,
|
||||
onDatasourceSave,
|
||||
onHide,
|
||||
show,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const [currentDatasource, setCurrentDatasource] = useState(datasource);
|
||||
const currencies = useSelector<
|
||||
{
|
||||
@@ -200,36 +189,13 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
const onConfirmSave = async () => {
|
||||
// Pull out extra fields into the extra object
|
||||
setIsSaving(true);
|
||||
const overrideColumns = datasource.sql !== currentDatasource.sql;
|
||||
try {
|
||||
await SupersetClient.put({
|
||||
endpoint: `/api/v1/dataset/${currentDatasource.id}`,
|
||||
endpoint: `/api/v1/dataset/${currentDatasource.id}?override_columns=${overrideColumns}`,
|
||||
jsonPayload: buildPayload(currentDatasource),
|
||||
});
|
||||
if (datasource.sql !== currentDatasource.sql) {
|
||||
// if sql has changed, save a second time with synced columns
|
||||
dispatch(startMetaDataLoading());
|
||||
try {
|
||||
const columnJson = await fetchSyncedColumns(currentDatasource);
|
||||
const columnChanges = updateColumns(
|
||||
currentDatasource.columns,
|
||||
columnJson,
|
||||
addSuccessToast,
|
||||
);
|
||||
currentDatasource.columns = columnChanges.finalColumns;
|
||||
dispatch(syncDatasourceMetadata(currentDatasource));
|
||||
dispatch(stopMetaDataLoading());
|
||||
addSuccessToast(t('Metadata has been synced'));
|
||||
} catch (error) {
|
||||
dispatch(stopMetaDataLoading());
|
||||
addDangerToast(
|
||||
t('An error has occurred while syncing virtual dataset columns'),
|
||||
);
|
||||
}
|
||||
await SupersetClient.put({
|
||||
endpoint: `/api/v1/dataset/${currentDatasource.id}`,
|
||||
jsonPayload: buildPayload(currentDatasource),
|
||||
});
|
||||
}
|
||||
|
||||
const { json } = await SupersetClient.get({
|
||||
endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ interface FieldProps<V> {
|
||||
onChange: (fieldKey: string, newValue: V) => void;
|
||||
compact: boolean;
|
||||
inline: boolean;
|
||||
errorMessage?: string;
|
||||
errorMessage?: string | ReactElement;
|
||||
}
|
||||
|
||||
export default function Field<V>({
|
||||
|
||||
@@ -28,10 +28,10 @@ export function recurseReactClone(children, type, propExtender) {
|
||||
*/
|
||||
return Children.map(children, child => {
|
||||
let newChild = child;
|
||||
if (child && child.type.name === type.name) {
|
||||
if (child && child.type && child.type.name === type.name) {
|
||||
newChild = cloneElement(child, propExtender(child));
|
||||
}
|
||||
if (newChild && newChild.props.children) {
|
||||
if (newChild && newChild.props && newChild.props.children) {
|
||||
newChild = cloneElement(newChild, {
|
||||
children: recurseReactClone(
|
||||
newChild.props.children,
|
||||
|
||||
@@ -16,29 +16,6 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { mount as enzymeMount } from 'enzyme';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { supersetTheme } from '@superset-ui/core';
|
||||
import { ReactElement } from 'react';
|
||||
import { ProviderWrapper } from './ProviderWrapper';
|
||||
|
||||
type optionsType = {
|
||||
wrappingComponentProps?: any;
|
||||
wrappingComponent?: ReactElement;
|
||||
context?: any;
|
||||
newOption?: string;
|
||||
};
|
||||
|
||||
export function styledMount(
|
||||
component: ReactElement,
|
||||
options: optionsType = {},
|
||||
): any {
|
||||
return enzymeMount(component, {
|
||||
...options,
|
||||
wrappingComponent: ProviderWrapper,
|
||||
wrappingComponentProps: {
|
||||
theme: supersetTheme,
|
||||
...options?.wrappingComponentProps,
|
||||
},
|
||||
});
|
||||
}
|
||||
export { Descriptions } from 'antd-v5';
|
||||
export type { DescriptionsProps } from 'antd-v5/es/descriptions';
|
||||
@@ -39,19 +39,21 @@ const PLACEMENTS = [
|
||||
'topRight',
|
||||
];
|
||||
|
||||
const theme = useTheme();
|
||||
export const InteractiveIconTooltip = (args: Props) => {
|
||||
const theme = useTheme();
|
||||
|
||||
export const InteractiveIconTooltip = (args: Props) => (
|
||||
<div
|
||||
css={css`
|
||||
margin: ${theme.gridUnit * 10}px ${theme.gridUnit * 17.5}px;
|
||||
`}
|
||||
>
|
||||
<IconTooltip {...args}>
|
||||
<Icons.InfoCircleOutlined />
|
||||
</IconTooltip>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
margin: ${theme.gridUnit * 10}px ${theme.gridUnit * 17.5}px;
|
||||
`}
|
||||
>
|
||||
<IconTooltip {...args}>
|
||||
<Icons.InfoCircleOutlined />
|
||||
</IconTooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
InteractiveIconTooltip.args = {
|
||||
tooltip: 'Tooltip',
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
FireOutlined,
|
||||
FormOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
FundProjectionScreenOutlined,
|
||||
@@ -115,6 +116,7 @@ import {
|
||||
UnorderedListOutlined,
|
||||
WarningOutlined,
|
||||
KeyOutlined,
|
||||
ExportOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { FC } from 'react';
|
||||
import { IconType } from './types';
|
||||
@@ -172,6 +174,7 @@ const AntdIcons = {
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
FireOutlined,
|
||||
FormOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
FundProjectionScreenOutlined,
|
||||
@@ -219,6 +222,7 @@ const AntdIcons = {
|
||||
UnorderedListOutlined,
|
||||
WarningOutlined,
|
||||
KeyOutlined,
|
||||
ExportOutlined,
|
||||
} as const;
|
||||
|
||||
type AntdIconNames = keyof typeof AntdIcons;
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useState, useCallback } from 'react';
|
||||
import { t } from '@superset-ui/core';
|
||||
|
||||
export interface FormModalProps extends ModalProps {
|
||||
initialValues: Object;
|
||||
initialValues?: Object;
|
||||
formSubmitHandler: (values: Object) => Promise<void>;
|
||||
onSave: () => void;
|
||||
requiredFields: string[];
|
||||
|
||||
@@ -204,3 +204,8 @@ export enum FilterPlugins {
|
||||
TimeColumn = 'filter_timecolumn',
|
||||
TimeGrain = 'filter_timegrain',
|
||||
}
|
||||
|
||||
export enum Actions {
|
||||
CREATE = 'create',
|
||||
UPDATE = 'update',
|
||||
}
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { styledMount as mount } from 'spec/helpers/theming';
|
||||
import { Provider } from 'react-redux';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import {
|
||||
supersetTheme,
|
||||
SupersetClient,
|
||||
ThemeProvider,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
import Modal from 'src/components/Modal';
|
||||
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
|
||||
import { mockStore } from 'spec/fixtures/mockStore';
|
||||
|
||||
const dashboardResult = {
|
||||
json: {
|
||||
result: {
|
||||
dashboard_title: 'New Title',
|
||||
slug: '/new',
|
||||
json_metadata: '{"something":"foo"}',
|
||||
owners: [],
|
||||
roles: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fetchMock.restore();
|
||||
|
||||
fetchMock.get('glob:*/api/v1/dashboard/related/owners?*', {
|
||||
result: [],
|
||||
});
|
||||
|
||||
fetchMock.get('glob:*/api/v1/dashboard/*', {
|
||||
result: {
|
||||
dashboard_title: 'New Title',
|
||||
slug: '/new',
|
||||
json_metadata: '{"something":"foo"}',
|
||||
owners: [],
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
const requiredProps = {
|
||||
dashboardId: 1,
|
||||
show: true,
|
||||
addSuccessToast: () => {},
|
||||
};
|
||||
|
||||
const setup = overrideProps =>
|
||||
mount(
|
||||
<Provider store={mockStore}>
|
||||
<PropertiesModal {...requiredProps} {...overrideProps} />
|
||||
</Provider>,
|
||||
{
|
||||
wrappingComponent: ThemeProvider,
|
||||
wrappingComponentProps: { theme: supersetTheme },
|
||||
},
|
||||
);
|
||||
|
||||
// all these tests need to be moved to dashboard/components/PropertiesModal/PropertiesModal.test.tsx
|
||||
describe.skip('PropertiesModal', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('onColorSchemeChange', () => {
|
||||
it('sets up a default state', () => {
|
||||
const wrapper = setup({ colorScheme: 'SUPERSET_DEFAULT' });
|
||||
expect(
|
||||
wrapper.find('PropertiesModal').instance().state.values.colorScheme,
|
||||
).toEqual('SUPERSET_DEFAULT');
|
||||
});
|
||||
describe('with a valid color scheme as an arg', () => {
|
||||
describe('without metadata', () => {
|
||||
const wrapper = setup({ colorScheme: 'SUPERSET_DEFAULT' });
|
||||
const modalInstance = wrapper.find('PropertiesModal').instance();
|
||||
it('updates the color scheme in the metadata', () => {
|
||||
const spy = jest.spyOn(modalInstance, 'onMetadataChange');
|
||||
modalInstance.onColorSchemeChange('SUPERSET_DEFAULT');
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'{"something": "foo", "color_scheme": "SUPERSET_DEFAULT", "label_colors": {}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('with metadata', () => {
|
||||
describe('with color_scheme in the metadata', () => {
|
||||
it('will update the metadata', () => {
|
||||
const wrapper = setup();
|
||||
const modalInstance = wrapper.find('PropertiesModal').instance();
|
||||
modalInstance.setState({
|
||||
values: {
|
||||
json_metadata: '{"color_scheme": "foo"}',
|
||||
},
|
||||
});
|
||||
const spy = jest.spyOn(modalInstance, 'onMetadataChange');
|
||||
modalInstance.onColorSchemeChange('SUPERSET_DEFAULT');
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'{"color_scheme": "SUPERSET_DEFAULT", "label_colors": {}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
it('without color_scheme in the metadata', () => {
|
||||
const wrapper = setup();
|
||||
const modalInstance = wrapper.find('PropertiesModal').instance();
|
||||
modalInstance.setState({
|
||||
values: {
|
||||
json_metadata: '{"timed_refresh_immune_slices": []}',
|
||||
},
|
||||
});
|
||||
it('will update the metadata', () => {
|
||||
const spy = jest.spyOn(modalInstance, 'onMetadataChange');
|
||||
modalInstance.onColorSchemeChange('SUPERSET_DEFAULT');
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'{"something": "foo", "color_scheme": "SUPERSET_DEFAULT", "label_colors": {}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('with an invalid color scheme as an arg', () => {
|
||||
const wrapper = setup();
|
||||
const modalInstance = wrapper.find('PropertiesModal').instance();
|
||||
it('will raise an error', () => {
|
||||
const spy = jest.spyOn(Modal, 'error');
|
||||
expect(() =>
|
||||
modalInstance.onColorSchemeChange('THIS_WILL_NOT_WORK'),
|
||||
).toThrow('A valid color scheme is required');
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('with an empty color scheme as an arg', () => {
|
||||
const wrapper = setup();
|
||||
const modalInstance = wrapper.find('PropertiesModal').instance();
|
||||
it('will not raise an error', () => {
|
||||
const spy = jest.spyOn(Modal, 'error');
|
||||
modalInstance.onColorSchemeChange('');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('onOwnersChange', () => {
|
||||
it('should update the state with the value passed', () => {
|
||||
const wrapper = setup();
|
||||
const modalInstance = wrapper.find('PropertiesModal').instance();
|
||||
const spy = jest.spyOn(modalInstance, 'updateFormState');
|
||||
const newOwners = [{ value: 1, label: 'foo' }];
|
||||
modalInstance.onOwnersChange(newOwners);
|
||||
expect(spy).toHaveBeenCalledWith('owners', newOwners);
|
||||
});
|
||||
});
|
||||
describe('onMetadataChange', () => {
|
||||
it('should update the state with the value passed', () => {
|
||||
const wrapper = setup();
|
||||
const modalInstance = wrapper.find('PropertiesModal').instance();
|
||||
const spy = jest.spyOn(modalInstance, 'updateFormState');
|
||||
modalInstance.onMetadataChange('foo');
|
||||
expect(spy).toHaveBeenCalledWith('json_metadata', 'foo');
|
||||
});
|
||||
});
|
||||
describe('onChange', () => {
|
||||
it('should update the state with the value passed', () => {
|
||||
const wrapper = setup();
|
||||
const modalInstance = wrapper.find('PropertiesModal').instance();
|
||||
const spy = jest.spyOn(modalInstance, 'updateFormState');
|
||||
modalInstance.onChange({ target: { name: 'test', value: 'foo' } });
|
||||
expect(spy).toHaveBeenCalledWith('test', 'foo');
|
||||
});
|
||||
});
|
||||
describe('fetchDashboardDetails', () => {
|
||||
it('should make an api call', () => {
|
||||
const spy = jest.spyOn(SupersetClient, 'get');
|
||||
const wrapper = setup();
|
||||
const modalInstance = wrapper.find('PropertiesModal').instance();
|
||||
modalInstance.fetchDashboardDetails();
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
endpoint: '/api/v1/dashboard/1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should update state', async () => {
|
||||
const wrapper = setup();
|
||||
const modalInstance = wrapper.find('PropertiesModal').instance();
|
||||
const fetchSpy = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockResolvedValue(dashboardResult);
|
||||
modalInstance.fetchDashboardDetails();
|
||||
await fetchSpy();
|
||||
expect(modalInstance.state.values.colorScheme).toBeUndefined();
|
||||
expect(modalInstance.state.values.dashboard_title).toEqual('New Title');
|
||||
expect(modalInstance.state.values.slug).toEqual('/new');
|
||||
expect(modalInstance.state.values.json_metadata).toEqual(
|
||||
'{"something": "foo"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call onOwnersChange', async () => {
|
||||
const wrapper = setup();
|
||||
const modalInstance = wrapper.find('PropertiesModal').instance();
|
||||
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: {
|
||||
result: {
|
||||
dashboard_title: 'New Title',
|
||||
slug: '/new',
|
||||
json_metadata: '{"something":"foo"}',
|
||||
owners: [{ id: 1, first_name: 'Al', last_name: 'Pacino' }],
|
||||
roles: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const onOwnersSpy = jest.spyOn(modalInstance, 'onOwnersChange');
|
||||
modalInstance.fetchDashboardDetails();
|
||||
await fetchSpy();
|
||||
expect(modalInstance.state.values.colorScheme).toBeUndefined();
|
||||
expect(onOwnersSpy).toHaveBeenCalledWith([
|
||||
{ value: 1, label: 'Al Pacino' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should call onRolesChange', async () => {
|
||||
const wrapper = setup();
|
||||
const modalInstance = wrapper.find('PropertiesModal').instance();
|
||||
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: {
|
||||
result: {
|
||||
dashboard_title: 'New Title',
|
||||
slug: '/new',
|
||||
json_metadata: '{"something":"foo"}',
|
||||
owners: [],
|
||||
roles: [{ id: 1, name: 'Alpha' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const onRolwesSpy = jest.spyOn(modalInstance, 'onRolesChange');
|
||||
modalInstance.fetchDashboardDetails();
|
||||
await fetchSpy();
|
||||
expect(modalInstance.state.values.colorScheme).toBeUndefined();
|
||||
expect(onRolwesSpy).toHaveBeenCalledWith([{ value: 1, label: 'Alpha' }]);
|
||||
});
|
||||
|
||||
describe('when colorScheme is undefined as a prop', () => {
|
||||
describe('when color_scheme is defined in json_metadata', () => {
|
||||
const wrapper = setup();
|
||||
const modalInstance = wrapper.find('PropertiesModal').instance();
|
||||
it('should use the color_scheme from json_metadata in the api response', async () => {
|
||||
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: {
|
||||
result: {
|
||||
dashboard_title: 'New Title',
|
||||
slug: '/new',
|
||||
json_metadata: '{"color_scheme":"SUPERSET_DEFAULT"}',
|
||||
owners: [],
|
||||
roles: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
modalInstance.fetchDashboardDetails();
|
||||
|
||||
// this below triggers the callback of the api call
|
||||
await fetchSpy();
|
||||
|
||||
expect(modalInstance.state.values.colorScheme).toEqual(
|
||||
'SUPERSET_DEFAULT',
|
||||
);
|
||||
});
|
||||
describe('when color_scheme is not defined in json_metadata', () => {
|
||||
const wrapper = setup();
|
||||
const modalInstance = wrapper.find('PropertiesModal').instance();
|
||||
it('should be undefined', async () => {
|
||||
const fetchSpy = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockResolvedValue(dashboardResult);
|
||||
modalInstance.fetchDashboardDetails();
|
||||
await fetchSpy();
|
||||
expect(modalInstance.state.values.colorScheme).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('when colorScheme is defined as a prop', () => {
|
||||
describe('when color_scheme is defined in json_metadata', () => {
|
||||
const wrapper = setup({ colorScheme: 'SUPERSET_DEFAULT' });
|
||||
const modalInstance = wrapper.find('PropertiesModal').instance();
|
||||
it('should use the color_scheme from json_metadata in the api response', async () => {
|
||||
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: {
|
||||
result: {
|
||||
dashboard_title: 'New Title',
|
||||
slug: '/new',
|
||||
json_metadata: '{"color_scheme":"SUPERSET_DEFAULT"}',
|
||||
owners: [],
|
||||
roles: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
modalInstance.fetchDashboardDetails();
|
||||
await fetchSpy();
|
||||
expect(modalInstance.state.values.colorScheme).toEqual(
|
||||
'SUPERSET_DEFAULT',
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('when color_scheme is not defined in json_metadata', () => {
|
||||
const wrapper = setup({ colorScheme: 'SUPERSET_DEFAULT' });
|
||||
const modalInstance = wrapper.find('PropertiesModal').instance();
|
||||
it('should use the colorScheme from the prop', async () => {
|
||||
const fetchSpy = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockResolvedValue(dashboardResult);
|
||||
modalInstance.fetchDashboardDetails();
|
||||
await fetchSpy();
|
||||
expect(modalInstance.state.values.colorScheme).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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 {
|
||||
screen,
|
||||
render,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import copyTextToClipboard from 'src/utils/copy';
|
||||
import ViewQuery, { ViewQueryProps } from './ViewQuery';
|
||||
|
||||
const mockHistoryPush = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useHistory: () => ({
|
||||
push: mockHistoryPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('src/utils/copy');
|
||||
|
||||
function setup(props: ViewQueryProps) {
|
||||
return render(<ViewQuery {...props} />, { useRouter: true, useRedux: true });
|
||||
}
|
||||
|
||||
const mockProps = {
|
||||
sql: 'select * from table',
|
||||
datasource: '1__table',
|
||||
};
|
||||
|
||||
const datasetApiEndpoint = 'glob:*/api/v1/dataset/1?**';
|
||||
const formatSqlEndpoint = 'glob:*/api/v1/sqllab/format_sql/';
|
||||
const formattedSQL = 'SELECT * FROM table;';
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.get(datasetApiEndpoint, {
|
||||
result: {
|
||||
database: {
|
||||
backend: 'sqlite',
|
||||
},
|
||||
},
|
||||
});
|
||||
fetchMock.post(formatSqlEndpoint, {
|
||||
result: formattedSQL,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
const getFormatSwitch = () =>
|
||||
screen.getByRole('switch', { name: 'Show original SQL' });
|
||||
|
||||
test('renders the component with Formatted SQL and buttons', async () => {
|
||||
const { container } = setup(mockProps);
|
||||
expect(screen.getByText('Copy')).toBeInTheDocument();
|
||||
expect(getFormatSwitch()).toBeInTheDocument();
|
||||
expect(screen.getByText('View in SQL Lab')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.calls(formatSqlEndpoint)).toHaveLength(1),
|
||||
);
|
||||
|
||||
expect(container).toHaveTextContent(formattedSQL);
|
||||
});
|
||||
|
||||
test('copies the SQL to the clipboard when Copy button is clicked', async () => {
|
||||
setup(mockProps);
|
||||
|
||||
(copyTextToClipboard as jest.Mock).mockResolvedValue('');
|
||||
const copyButton = screen.getByText('Copy');
|
||||
expect(copyTextToClipboard as jest.Mock).not.toHaveBeenCalled();
|
||||
fireEvent.click(copyButton);
|
||||
expect(copyTextToClipboard as jest.Mock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('shows the original SQL when Format switch is unchecked', async () => {
|
||||
const { container } = setup(mockProps);
|
||||
const formatButton = getFormatSwitch();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.calls(formatSqlEndpoint)).toHaveLength(1),
|
||||
);
|
||||
|
||||
fireEvent.click(formatButton);
|
||||
|
||||
expect(container).toHaveTextContent(mockProps.sql);
|
||||
});
|
||||
|
||||
test('toggles back to formatted SQL when Format switch is clicked', async () => {
|
||||
const { container } = setup(mockProps);
|
||||
const formatButton = getFormatSwitch();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.calls(formatSqlEndpoint)).toHaveLength(1),
|
||||
);
|
||||
|
||||
// Click to format SQL
|
||||
fireEvent.click(formatButton);
|
||||
|
||||
await waitFor(() => expect(container).toHaveTextContent(mockProps.sql));
|
||||
|
||||
// Toggle format switch
|
||||
fireEvent.click(formatButton);
|
||||
|
||||
await waitFor(() => expect(container).toHaveTextContent(formattedSQL));
|
||||
});
|
||||
|
||||
test('navigates to SQL Lab when View in SQL Lab button is clicked', () => {
|
||||
setup(mockProps);
|
||||
|
||||
const viewInSQLLabButton = screen.getByText('View in SQL Lab');
|
||||
fireEvent.click(viewInSQLLabButton);
|
||||
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith('/sqllab', {
|
||||
state: {
|
||||
requestedQuery: {
|
||||
datasourceKey: mockProps.datasource,
|
||||
sql: mockProps.sql,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('opens SQL Lab in a new tab when View in SQL Lab button is clicked with meta key', () => {
|
||||
window.open = jest.fn();
|
||||
|
||||
setup(mockProps);
|
||||
const viewInSQLLabButton = screen.getByText('View in SQL Lab');
|
||||
|
||||
fireEvent.click(viewInSQLLabButton, { metaKey: true });
|
||||
|
||||
const { datasource, sql } = mockProps;
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
`/sqllab?datasourceKey=${datasource}&sql=${sql}`,
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
@@ -16,10 +16,16 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
// TODO: Remove fa-icon
|
||||
/* eslint-disable icons/no-fa-icons-usage */
|
||||
import { FC } from 'react';
|
||||
import { styled } from '@superset-ui/core';
|
||||
import {
|
||||
FC,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import rison from 'rison';
|
||||
import { styled, SupersetClient, t } from '@superset-ui/core';
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light';
|
||||
import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github';
|
||||
import CopyToClipboard from 'src/components/CopyToClipboard';
|
||||
@@ -28,6 +34,9 @@ import markdownSyntax from 'react-syntax-highlighter/dist/cjs/languages/hljs/mar
|
||||
import htmlSyntax from 'react-syntax-highlighter/dist/cjs/languages/hljs/htmlbars';
|
||||
import sqlSyntax from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql';
|
||||
import jsonSyntax from 'react-syntax-highlighter/dist/cjs/languages/hljs/json';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Switch } from 'src/components/Switch';
|
||||
import { Button, Skeleton } from 'src/components';
|
||||
|
||||
const CopyButtonViewQuery = styled(CopyButton)`
|
||||
&& {
|
||||
@@ -40,8 +49,9 @@ SyntaxHighlighter.registerLanguage('html', htmlSyntax);
|
||||
SyntaxHighlighter.registerLanguage('sql', sqlSyntax);
|
||||
SyntaxHighlighter.registerLanguage('json', jsonSyntax);
|
||||
|
||||
interface ViewQueryProps {
|
||||
export interface ViewQueryProps {
|
||||
sql: string;
|
||||
datasource: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
@@ -51,26 +61,124 @@ const StyledSyntaxContainer = styled.div`
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledHeaderMenuContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin-top: ${({ theme }) => -theme.gridUnit * 4}px;
|
||||
align-items: flex-end;
|
||||
`;
|
||||
|
||||
const StyledHeaderActionContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
column-gap: ${({ theme }) => theme.gridUnit * 2}px;
|
||||
`;
|
||||
|
||||
const StyledSyntaxHighlighter = styled(SyntaxHighlighter)`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const StyledLabel = styled.label`
|
||||
font-size: ${({ theme }) => theme.typography.sizes.m}px;
|
||||
`;
|
||||
|
||||
const DATASET_BACKEND_QUERY = {
|
||||
keys: ['none'],
|
||||
columns: ['database.backend'],
|
||||
};
|
||||
|
||||
const ViewQuery: FC<ViewQueryProps> = props => {
|
||||
const { sql, language = 'sql' } = props;
|
||||
const { sql, language = 'sql', datasource } = props;
|
||||
const datasetId = datasource.split('__')[0];
|
||||
const [formattedSQL, setFormattedSQL] = useState<string>();
|
||||
const [showFormatSQL, setShowFormatSQL] = useState(true);
|
||||
const history = useHistory();
|
||||
const currentSQL = (showFormatSQL ? formattedSQL : sql) ?? sql;
|
||||
|
||||
const formatCurrentQuery = useCallback(() => {
|
||||
if (formattedSQL) {
|
||||
setShowFormatSQL(val => !val);
|
||||
} else {
|
||||
const queryParams = rison.encode(DATASET_BACKEND_QUERY);
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dataset/${datasetId}?q=${queryParams}`,
|
||||
})
|
||||
.then(({ json }) =>
|
||||
SupersetClient.post({
|
||||
endpoint: `/api/v1/sqllab/format_sql/`,
|
||||
body: JSON.stringify({
|
||||
sql,
|
||||
engine: json.result.database.backend,
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
.then(({ json }) => {
|
||||
setFormattedSQL(json.result);
|
||||
setShowFormatSQL(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setShowFormatSQL(true);
|
||||
});
|
||||
}
|
||||
}, [sql, datasetId, formattedSQL]);
|
||||
|
||||
const navToSQLLab = useCallback(
|
||||
(domEvent: KeyboardEvent<HTMLElement> | MouseEvent<HTMLElement>) => {
|
||||
const requestedQuery = {
|
||||
datasourceKey: datasource,
|
||||
sql: currentSQL,
|
||||
};
|
||||
if (domEvent.metaKey || domEvent.ctrlKey) {
|
||||
domEvent.preventDefault();
|
||||
window.open(
|
||||
`/sqllab?datasourceKey=${datasource}&sql=${currentSQL}`,
|
||||
'_blank',
|
||||
);
|
||||
} else {
|
||||
history.push('/sqllab', { state: { requestedQuery } });
|
||||
}
|
||||
},
|
||||
[history, datasource, currentSQL],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
formatCurrentQuery();
|
||||
}, [sql]);
|
||||
|
||||
return (
|
||||
<StyledSyntaxContainer key={sql}>
|
||||
<CopyToClipboard
|
||||
text={sql}
|
||||
shouldShowText={false}
|
||||
copyNode={
|
||||
<CopyButtonViewQuery buttonSize="xsmall">
|
||||
<i className="fa fa-clipboard" />
|
||||
</CopyButtonViewQuery>
|
||||
}
|
||||
/>
|
||||
<StyledSyntaxHighlighter language={language} style={github}>
|
||||
{sql}
|
||||
</StyledSyntaxHighlighter>
|
||||
<StyledHeaderMenuContainer>
|
||||
<StyledHeaderActionContainer>
|
||||
<CopyToClipboard
|
||||
text={currentSQL}
|
||||
shouldShowText={false}
|
||||
copyNode={
|
||||
<CopyButtonViewQuery buttonSize="small">
|
||||
{t('Copy')}
|
||||
</CopyButtonViewQuery>
|
||||
}
|
||||
/>
|
||||
<Button onClick={navToSQLLab}>{t('View in SQL Lab')}</Button>
|
||||
</StyledHeaderActionContainer>
|
||||
<StyledHeaderActionContainer>
|
||||
<Switch
|
||||
id="formatSwitch"
|
||||
checked={!showFormatSQL}
|
||||
onChange={formatCurrentQuery}
|
||||
/>
|
||||
<StyledLabel htmlFor="formatSwitch">
|
||||
{t('Show original SQL')}
|
||||
</StyledLabel>
|
||||
</StyledHeaderActionContainer>
|
||||
</StyledHeaderMenuContainer>
|
||||
{!formattedSQL && <Skeleton active />}
|
||||
{formattedSQL && (
|
||||
<StyledSyntaxHighlighter language={language} style={github}>
|
||||
{currentSQL}
|
||||
</StyledSyntaxHighlighter>
|
||||
)}
|
||||
</StyledSyntaxContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,13 +23,14 @@ import {
|
||||
ensureIsArray,
|
||||
t,
|
||||
getClientErrorObject,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import Loading from 'src/components/Loading';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
import ViewQuery from 'src/explore/components/controls/ViewQuery';
|
||||
|
||||
interface Props {
|
||||
latestQueryFormData: object;
|
||||
latestQueryFormData: QueryFormData;
|
||||
}
|
||||
|
||||
type Result = {
|
||||
@@ -43,7 +44,7 @@ const ViewQueryModalContainer = styled.div`
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const ViewQueryModal: FC<Props> = props => {
|
||||
const ViewQueryModal: FC<Props> = ({ latestQueryFormData }) => {
|
||||
const [result, setResult] = useState<Result[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -51,7 +52,7 @@ const ViewQueryModal: FC<Props> = props => {
|
||||
const loadChartData = (resultType: string) => {
|
||||
setIsLoading(true);
|
||||
getChartDataRequest({
|
||||
formData: props.latestQueryFormData,
|
||||
formData: latestQueryFormData,
|
||||
resultFormat: 'json',
|
||||
resultType,
|
||||
})
|
||||
@@ -74,7 +75,7 @@ const ViewQueryModal: FC<Props> = props => {
|
||||
};
|
||||
useEffect(() => {
|
||||
loadChartData('query');
|
||||
}, [JSON.stringify(props.latestQueryFormData)]);
|
||||
}, [JSON.stringify(latestQueryFormData)]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
@@ -87,7 +88,11 @@ const ViewQueryModal: FC<Props> = props => {
|
||||
<ViewQueryModalContainer>
|
||||
{result.map(item =>
|
||||
item.query ? (
|
||||
<ViewQuery sql={item.query} language={item.language || undefined} />
|
||||
<ViewQuery
|
||||
datasource={latestQueryFormData.datasource}
|
||||
sql={item.query}
|
||||
language={item.language || undefined}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</ViewQueryModalContainer>
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('AllEntitiesTable', () => {
|
||||
expect(screen.queryByText('Add tag to entities')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the correct tags for each object type, excluding the current tag', () => {
|
||||
it('renders the correct tags for each object type', () => {
|
||||
render(
|
||||
<AllEntitiesTable
|
||||
search=""
|
||||
@@ -149,8 +149,6 @@ describe('AllEntitiesTable', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -23,7 +23,6 @@ import { TagsList } from 'src/components/Tags';
|
||||
import FacePile from 'src/components/FacePile';
|
||||
import Tag from 'src/types/TagType';
|
||||
import { EmptyState } from 'src/components/EmptyState';
|
||||
import { NumberParam, useQueryParam } from 'use-query-params';
|
||||
import { TaggedObject, TaggedObjects } from 'src/types/TaggedObject';
|
||||
|
||||
const MAX_TAGS_TO_SHOW = 3;
|
||||
@@ -64,7 +63,6 @@ export default function AllEntitiesTable({
|
||||
}: AllEntitiesTableProps) {
|
||||
type objectType = 'dashboard' | 'chart' | 'query';
|
||||
|
||||
const [tagId] = useQueryParam('id', NumberParam);
|
||||
const showDashboardList = objects.dashboard.length > 0;
|
||||
const showChartList = objects.chart.length > 0;
|
||||
const showQueryList = objects.query.length > 0;
|
||||
@@ -106,8 +104,7 @@ export default function AllEntitiesTable({
|
||||
tags={tags.filter(
|
||||
(tag: Tag) =>
|
||||
tag.type !== undefined &&
|
||||
['TagType.custom', 1].includes(tag.type) &&
|
||||
tag.id !== tagId,
|
||||
['TagType.custom', 1].includes(tag.type),
|
||||
)}
|
||||
maxTags={MAX_TAGS_TO_SHOW}
|
||||
/>
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import thunk from 'redux-thunk';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import { Provider } from 'react-redux';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import Modal from 'src/components/Modal';
|
||||
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
|
||||
import { styledMount as mount } from 'spec/helpers/theming';
|
||||
import AnnotationLayerModal from './AnnotationLayerModal';
|
||||
|
||||
const mockData = { id: 1, name: 'test', descr: 'test description' };
|
||||
const FETCH_ANNOTATION_LAYER_ENDPOINT = 'glob:*/api/v1/annotation_layer/*';
|
||||
const ANNOTATION_LAYER_PAYLOAD = { result: mockData };
|
||||
|
||||
fetchMock.get(FETCH_ANNOTATION_LAYER_ENDPOINT, ANNOTATION_LAYER_PAYLOAD);
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
const mockedProps = {
|
||||
addDangerToast: () => {},
|
||||
onLayerAdd: jest.fn(() => []),
|
||||
onHide: () => {},
|
||||
show: true,
|
||||
layer: mockData,
|
||||
};
|
||||
|
||||
async function mountAndWait(props = mockedProps) {
|
||||
const mounted = mount(
|
||||
<Provider store={store}>
|
||||
<AnnotationLayerModal show {...props} />
|
||||
</Provider>,
|
||||
);
|
||||
await waitForComponentToPaint(mounted);
|
||||
|
||||
return mounted;
|
||||
}
|
||||
|
||||
describe('AnnotationLayerModal', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeAll(async () => {
|
||||
wrapper = await mountAndWait();
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
expect(wrapper.find(AnnotationLayerModal)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a Modal', () => {
|
||||
expect(wrapper.find(Modal)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders add header when no layer is included', async () => {
|
||||
const addWrapper = await mountAndWait({});
|
||||
expect(
|
||||
addWrapper.find('[data-test="annotation-layer-modal-title"]').text(),
|
||||
).toEqual('Add annotation layer');
|
||||
});
|
||||
|
||||
it('renders edit header when layer prop is included', () => {
|
||||
expect(
|
||||
wrapper.find('[data-test="annotation-layer-modal-title"]').text(),
|
||||
).toEqual('Edit annotation layer properties');
|
||||
});
|
||||
|
||||
it('renders input element for name', () => {
|
||||
expect(wrapper.find('input[name="name"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders textarea element for description', () => {
|
||||
expect(wrapper.find('textarea[name="descr"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import thunk from 'redux-thunk';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import { Provider } from 'react-redux';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import Modal from 'src/components/Modal';
|
||||
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
|
||||
import { JsonEditor } from 'src/components/AsyncAceEditor';
|
||||
import { styledMount as mount } from 'spec/helpers/theming';
|
||||
import AnnotationModal from './AnnotationModal';
|
||||
|
||||
const mockData = {
|
||||
id: 1,
|
||||
short_descr: 'annotation 1',
|
||||
start_dttm: '2019-07-01T10:25:00',
|
||||
end_dttm: '2019-06-11T10:25:00',
|
||||
};
|
||||
|
||||
const FETCH_ANNOTATION_ENDPOINT =
|
||||
'glob:*/api/v1/annotation_layer/*/annotation/*';
|
||||
const ANNOTATION_PAYLOAD = { result: mockData };
|
||||
|
||||
fetchMock.get(FETCH_ANNOTATION_ENDPOINT, ANNOTATION_PAYLOAD);
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
const mockedProps = {
|
||||
addDangerToast: () => {},
|
||||
addSuccessToast: () => {},
|
||||
annotation: mockData,
|
||||
onAnnotationAdd: jest.fn(() => []),
|
||||
onHide: () => {},
|
||||
show: true,
|
||||
};
|
||||
|
||||
async function mountAndWait(props = mockedProps) {
|
||||
const mounted = mount(
|
||||
<Provider store={store}>
|
||||
<AnnotationModal show {...props} />
|
||||
</Provider>,
|
||||
);
|
||||
await waitForComponentToPaint(mounted);
|
||||
return mounted;
|
||||
}
|
||||
|
||||
describe('AnnotationModal', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeAll(async () => {
|
||||
wrapper = await mountAndWait();
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
expect(wrapper.find(AnnotationModal)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a Modal', () => {
|
||||
expect(wrapper.find(Modal)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders add header when no annotation prop is included', async () => {
|
||||
const addWrapper = await mountAndWait({});
|
||||
expect(
|
||||
addWrapper.find('[data-test="annotation-modal-title"]').text(),
|
||||
).toEqual('Add annotation');
|
||||
});
|
||||
|
||||
it('renders edit header when annotation prop is included', () => {
|
||||
expect(wrapper.find('[data-test="annotation-modal-title"]').text()).toEqual(
|
||||
'Edit annotation',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders input elements for annotation name', () => {
|
||||
expect(wrapper.find('input[name="short_descr"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders json editor for json metadata', () => {
|
||||
expect(wrapper.find(JsonEditor)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import thunk from 'redux-thunk';
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import Modal from 'src/components/Modal';
|
||||
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
|
||||
import { CssEditor } from 'src/components/AsyncAceEditor';
|
||||
import { styledMount as mount } from 'spec/helpers/theming';
|
||||
import CssTemplateModal from './CssTemplateModal';
|
||||
|
||||
const mockData = { id: 1, template_name: 'test' };
|
||||
const FETCH_CSS_TEMPLATE_ENDPOINT = 'glob:*/api/v1/css_template/*';
|
||||
const CSS_TEMPLATE_PAYLOAD = { result: mockData };
|
||||
|
||||
fetchMock.get(FETCH_CSS_TEMPLATE_ENDPOINT, CSS_TEMPLATE_PAYLOAD);
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
const mockedProps = {
|
||||
addDangerToast: () => {},
|
||||
onCssTemplateAdd: jest.fn(() => []),
|
||||
onHide: () => {},
|
||||
show: true,
|
||||
cssTemplate: mockData,
|
||||
};
|
||||
|
||||
async function mountAndWait(props = mockedProps) {
|
||||
const mounted = mount(
|
||||
<Provider store={store}>
|
||||
<CssTemplateModal show {...props} />
|
||||
</Provider>,
|
||||
);
|
||||
await waitForComponentToPaint(mounted);
|
||||
|
||||
return mounted;
|
||||
}
|
||||
|
||||
describe('CssTemplateModal', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeAll(async () => {
|
||||
wrapper = await mountAndWait();
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
expect(wrapper.find(CssTemplateModal)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a Modal', () => {
|
||||
expect(wrapper.find(Modal)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders add header when no css template is included', async () => {
|
||||
const addWrapper = await mountAndWait({});
|
||||
expect(
|
||||
addWrapper.find('[data-test="css-template-modal-title"]').text(),
|
||||
).toEqual('Add CSS template');
|
||||
});
|
||||
|
||||
it('renders edit header when css template prop is included', () => {
|
||||
expect(
|
||||
wrapper.find('[data-test="css-template-modal-title"]').text(),
|
||||
).toEqual('Edit CSS template properties');
|
||||
});
|
||||
|
||||
it('renders input elements for template name', () => {
|
||||
expect(wrapper.find('input[name="template_name"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders css editor for css', () => {
|
||||
expect(wrapper.find(CssEditor)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -159,7 +159,7 @@ function DashboardCard({
|
||||
}
|
||||
url={bulkSelectEnabled ? undefined : dashboard.url}
|
||||
linkComponent={Link}
|
||||
imgURL={dashboard.thumbnail_url}
|
||||
imgURL={thumbnailUrl}
|
||||
imgFallbackURL={assetUrl(
|
||||
'/static/assets/images/dashboard-card-fallback.svg',
|
||||
)}
|
||||
|
||||
164
superset-frontend/src/features/groups/GroupListModal.tsx
Normal file
164
superset-frontend/src/features/groups/GroupListModal.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@superset-ui/core';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import FormModal from 'src/components/Modal/FormModal';
|
||||
import { FormItem } from 'src/components/Form';
|
||||
import { Input } from 'src/components/Input';
|
||||
import Select from 'src/components/Select/Select';
|
||||
import { GroupObject, Role } from 'src/pages/GroupsList';
|
||||
import { Actions } from 'src/constants';
|
||||
import AsyncSelect from 'src/components/Select/AsyncSelect';
|
||||
import { BaseGroupListModalProps, FormValues } from './types';
|
||||
import { createGroup, fetchUserOptions, updateGroup } from './utils';
|
||||
|
||||
export interface GroupModalProps extends BaseGroupListModalProps {
|
||||
roles: Role[];
|
||||
isEditMode?: boolean;
|
||||
group?: GroupObject;
|
||||
}
|
||||
|
||||
function GroupListModal({
|
||||
show,
|
||||
onHide,
|
||||
onSave,
|
||||
roles,
|
||||
isEditMode = false,
|
||||
group,
|
||||
}: GroupModalProps) {
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
const handleFormSubmit = async (values: FormValues) => {
|
||||
const handleError = async (
|
||||
err: Response,
|
||||
action: Actions.CREATE | Actions.UPDATE,
|
||||
) => {
|
||||
let errorMessage =
|
||||
action === Actions.CREATE
|
||||
? t('There was an error creating the group. Please, try again.')
|
||||
: t('There was an error updating the group. Please, try again.');
|
||||
|
||||
if (err.status === 422) {
|
||||
const errorData = await err.json();
|
||||
const detail = errorData?.message || '';
|
||||
|
||||
if (detail.includes('duplicate key value')) {
|
||||
if (detail.includes('ab_group_name_key')) {
|
||||
errorMessage = t(
|
||||
'This name is already taken. Please choose another one.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addDangerToast(errorMessage);
|
||||
throw err;
|
||||
};
|
||||
|
||||
if (isEditMode) {
|
||||
if (!group) {
|
||||
throw new Error('Group is required in edit mode');
|
||||
}
|
||||
try {
|
||||
await updateGroup(group.id, values);
|
||||
addSuccessToast(t('The group has been updated successfully.'));
|
||||
} catch (err) {
|
||||
await handleError(err, Actions.UPDATE);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await createGroup(values);
|
||||
addSuccessToast(t('The group has been created successfully.'));
|
||||
} catch (err) {
|
||||
await handleError(err, Actions.CREATE);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const requiredFields = ['name'];
|
||||
const initialValues = {
|
||||
...group,
|
||||
roles: group?.roles?.map(role => role.id) || [],
|
||||
users:
|
||||
group?.users?.map(user => ({
|
||||
value: user.id,
|
||||
label: user.username,
|
||||
})) || [],
|
||||
};
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
title={isEditMode ? t('Edit Group') : t('Add Group')}
|
||||
onSave={onSave}
|
||||
formSubmitHandler={handleFormSubmit}
|
||||
requiredFields={requiredFields}
|
||||
initialValues={initialValues}
|
||||
>
|
||||
<FormItem
|
||||
name="name"
|
||||
label={t('Name')}
|
||||
rules={[{ required: true, message: t('Name is required') }]}
|
||||
>
|
||||
<Input name="name" placeholder={t("Enter the group's name")} />
|
||||
</FormItem>
|
||||
<FormItem name="label" label={t('Label')}>
|
||||
<Input name="label" placeholder={t("Enter the group's label")} />
|
||||
</FormItem>
|
||||
<FormItem name="description" label={t('Description')}>
|
||||
<Input
|
||||
name="description"
|
||||
placeholder={t("Enter the group's description")}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem name="roles" label={t('Roles')}>
|
||||
<Select
|
||||
name="roles"
|
||||
mode="multiple"
|
||||
placeholder={t('Select roles')}
|
||||
options={roles.map(role => ({
|
||||
value: role.id,
|
||||
label: role.name,
|
||||
}))}
|
||||
getPopupContainer={trigger => trigger.closest('.antd5-modal-content')}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem name="users" label={t('Users')}>
|
||||
<AsyncSelect
|
||||
name="users"
|
||||
mode="multiple"
|
||||
placeholder={t('Select users')}
|
||||
options={(filterValue, page, pageSize) =>
|
||||
fetchUserOptions(filterValue, page, pageSize, addDangerToast)
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
export const GroupListAddModal = (
|
||||
props: Omit<GroupModalProps, 'isEditMode' | 'initialValues'>,
|
||||
) => <GroupListModal {...props} isEditMode={false} />;
|
||||
|
||||
export const GroupListEditModal = (
|
||||
props: Omit<GroupModalProps, 'isEditMode'> & { group: GroupObject },
|
||||
) => <GroupListModal {...props} isEditMode />;
|
||||
|
||||
export default GroupListModal;
|
||||
@@ -16,19 +16,16 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactWrapper } from 'enzyme';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
// taken from: https://github.com/enzymejs/enzyme/issues/2073
|
||||
// There is currently and issue with enzyme and react-16's hooks
|
||||
// that results in a race condition between tests and react hook updates.
|
||||
// This function ensures tests run after all react updates are done.
|
||||
export default async function waitForComponentToPaint<P = {}>(
|
||||
wrapper: ReactWrapper<P>,
|
||||
amount = 0,
|
||||
) {
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, amount));
|
||||
wrapper.update();
|
||||
});
|
||||
export interface BaseGroupListModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export interface FormValues {
|
||||
name: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
roles: number[];
|
||||
users: { value: number; label: string }[];
|
||||
}
|
||||
74
superset-frontend/src/features/groups/utils.ts
Normal file
74
superset-frontend/src/features/groups/utils.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { SupersetClient, t } from '@superset-ui/core';
|
||||
import rison from 'rison';
|
||||
import { FormValues } from './types';
|
||||
|
||||
export const createGroup = async (values: FormValues) => {
|
||||
await SupersetClient.post({
|
||||
endpoint: '/api/v1/security/groups/',
|
||||
jsonPayload: { ...values, users: values.users.map(user => user.value) },
|
||||
});
|
||||
};
|
||||
|
||||
export const updateGroup = async (groupId: number, values: FormValues) => {
|
||||
await SupersetClient.put({
|
||||
endpoint: `/api/v1/security/groups/${groupId}`,
|
||||
jsonPayload: { ...values, users: values.users.map(user => user.value) },
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteGroup = async (groupId: number) =>
|
||||
SupersetClient.delete({
|
||||
endpoint: `/api/v1/security/groups/${groupId}`,
|
||||
});
|
||||
|
||||
export const fetchUserOptions = async (
|
||||
filterValue: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
addDangerToast: (msg: string) => void,
|
||||
) => {
|
||||
const query = rison.encode({
|
||||
filter: filterValue,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
order_column: 'username',
|
||||
order_direction: 'asc',
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: `/api/v1/security/users/?q=${query}`,
|
||||
});
|
||||
|
||||
const results = response.json?.result || [];
|
||||
|
||||
return {
|
||||
data: results.map((user: any) => ({
|
||||
value: user.id,
|
||||
label: user.username,
|
||||
})),
|
||||
totalCount: response.json?.count ?? 0,
|
||||
};
|
||||
} catch (error) {
|
||||
addDangerToast(t('There was an error while fetching users'));
|
||||
return { data: [], totalCount: 0 };
|
||||
}
|
||||
};
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import thunk from 'redux-thunk';
|
||||
import { styledMount as mount } from 'spec/helpers/theming';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import { act } from 'spec/helpers/testing-library';
|
||||
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
|
||||
import SubMenu from './SubMenu';
|
||||
import SavedQueries from './SavedQueries';
|
||||
|
||||
// store needed for withToasts(DashboardTable)
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
const queriesEndpoint = 'glob:*/api/v1/saved_query/?*';
|
||||
const savedQueriesInfo = 'glob:*/api/v1/saved_query/_info*';
|
||||
|
||||
const mockqueries = [...new Array(3)].map((_, i) => ({
|
||||
created_by: {
|
||||
id: i,
|
||||
first_name: `user`,
|
||||
last_name: `${i}`,
|
||||
},
|
||||
created_on: `${i}-2020`,
|
||||
database: {
|
||||
database_name: `db ${i}`,
|
||||
id: i,
|
||||
},
|
||||
changed_on_delta_humanized: '1 day ago',
|
||||
db_id: i,
|
||||
description: `SQL for ${i}`,
|
||||
id: i,
|
||||
label: `query ${i}`,
|
||||
schema: 'public',
|
||||
sql: `SELECT ${i} FROM table`,
|
||||
sql_tables: [
|
||||
{
|
||||
catalog: null,
|
||||
schema: null,
|
||||
table: `${i}`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
fetchMock.get(queriesEndpoint, {
|
||||
result: mockqueries,
|
||||
});
|
||||
|
||||
fetchMock.get(savedQueriesInfo, {
|
||||
permissions: ['can_list', 'can_edit', 'can_delete'],
|
||||
});
|
||||
|
||||
describe('SavedQueries', () => {
|
||||
const savedQueryProps = {
|
||||
user: {
|
||||
userId: '1',
|
||||
},
|
||||
mine: mockqueries,
|
||||
};
|
||||
|
||||
const wrapper = mount(<SavedQueries store={store} {...savedQueryProps} />);
|
||||
|
||||
const clickTab = (idx: number) => {
|
||||
act(() => {
|
||||
const handler = wrapper.find('[role="tab"] a').at(idx).prop('onClick');
|
||||
if (handler) {
|
||||
handler({} as any);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await waitForComponentToPaint(wrapper);
|
||||
});
|
||||
|
||||
it('is valid', () => {
|
||||
expect(wrapper.find(SavedQueries)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fetches queries mine and renders listviewcard cards', async () => {
|
||||
clickTab(0);
|
||||
await waitForComponentToPaint(wrapper);
|
||||
expect(fetchMock.calls(/saved_query\/\?q/)).toHaveLength(1);
|
||||
expect(wrapper.find('ListViewCard')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a submenu with clickable tables and buttons', async () => {
|
||||
expect(wrapper.find(SubMenu)).toBeTruthy();
|
||||
expect(wrapper.find('[role="tab"]')).toHaveLength(1);
|
||||
expect(wrapper.find('button')).toHaveLength(5);
|
||||
clickTab(0);
|
||||
await waitForComponentToPaint(wrapper);
|
||||
expect(fetchMock.calls(/saved_query\/\?q/)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { MouseEvent } from 'react';
|
||||
import thunk from 'redux-thunk';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
|
||||
import { styledMount as mount } from 'spec/helpers/theming';
|
||||
|
||||
import { QueryObject } from 'src/views/CRUD/types';
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light';
|
||||
import { act } from 'spec/helpers/testing-library';
|
||||
import { QueryState } from '@superset-ui/core';
|
||||
import QueryPreviewModal from './QueryPreviewModal';
|
||||
|
||||
// store needed for withToasts
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
const mockQueries: QueryObject[] = [...new Array(3)].map((_, i) => ({
|
||||
changed_on: new Date().toISOString(),
|
||||
id: i,
|
||||
slice_name: `cool chart ${i}`,
|
||||
database: {
|
||||
database_name: 'main db',
|
||||
},
|
||||
schema: 'public',
|
||||
sql: `SELECT ${i} FROM table`,
|
||||
executed_sql: `SELECT ${i} FROM table LIMIT 1000`,
|
||||
sql_tables: [
|
||||
{ schema: 'foo', table: 'table' },
|
||||
{ schema: 'bar', table: 'table_2' },
|
||||
],
|
||||
status: QueryState.Success,
|
||||
tab_name: 'Main Tab',
|
||||
user: {
|
||||
first_name: 'cool',
|
||||
last_name: 'dude',
|
||||
id: 2,
|
||||
username: 'cooldude',
|
||||
},
|
||||
start_time: new Date().valueOf(),
|
||||
end_time: new Date().valueOf(),
|
||||
rows: 200,
|
||||
tmp_table_name: '',
|
||||
tracking_url: '',
|
||||
}));
|
||||
|
||||
describe('QueryPreviewModal', () => {
|
||||
let currentIndex = 0;
|
||||
let currentQuery = mockQueries[currentIndex];
|
||||
const mockedProps = {
|
||||
onHide: jest.fn(),
|
||||
openInSqlLab: jest.fn(),
|
||||
queries: mockQueries,
|
||||
query: currentQuery,
|
||||
fetchData: jest.fn(() => {
|
||||
currentIndex += 1;
|
||||
currentQuery = mockQueries[currentIndex];
|
||||
}),
|
||||
show: true,
|
||||
};
|
||||
const wrapper = mount(<QueryPreviewModal store={store} {...mockedProps} />);
|
||||
|
||||
beforeAll(async () => {
|
||||
await waitForComponentToPaint(wrapper);
|
||||
});
|
||||
|
||||
it('renders a SyntaxHighlighter', () => {
|
||||
expect(wrapper.find(SyntaxHighlighter)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggles between user sql and executed sql', () => {
|
||||
expect(
|
||||
wrapper.find(SyntaxHighlighter).props().children,
|
||||
).toMatchInlineSnapshot(`"SELECT 0 FROM table"`);
|
||||
|
||||
act(() => {
|
||||
const props = wrapper
|
||||
.find('[data-test="toggle-executed-sql"]')
|
||||
.first()
|
||||
.props();
|
||||
|
||||
if (typeof props.onClick === 'function') {
|
||||
props.onClick({} as MouseEvent);
|
||||
}
|
||||
});
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(
|
||||
wrapper.find(SyntaxHighlighter).props().children,
|
||||
).toMatchInlineSnapshot(`"SELECT 0 FROM table LIMIT 1000"`);
|
||||
});
|
||||
|
||||
describe('Previous button', () => {
|
||||
it('disabled when query is the first in list', () => {
|
||||
expect(
|
||||
wrapper.find('[data-test="previous-query"]').first().props().disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('falls fetchData with previous index', () => {
|
||||
const mockedProps2 = {
|
||||
...mockedProps,
|
||||
query: mockQueries[1],
|
||||
};
|
||||
const wrapper2 = mount(
|
||||
<QueryPreviewModal store={store} {...mockedProps2} />,
|
||||
);
|
||||
act(() => {
|
||||
const props = wrapper2
|
||||
.find('[data-test="previous-query"]')
|
||||
.first()
|
||||
.props();
|
||||
if (typeof props.onClick === 'function') {
|
||||
props.onClick({} as MouseEvent);
|
||||
}
|
||||
});
|
||||
|
||||
expect(mockedProps2.fetchData).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Next button', () => {
|
||||
it('calls fetchData with next index', () => {
|
||||
act(() => {
|
||||
const props = wrapper.find('[data-test="next-query"]').first().props();
|
||||
if (typeof props.onClick === 'function') {
|
||||
props.onClick({} as MouseEvent);
|
||||
}
|
||||
});
|
||||
|
||||
expect(mockedProps.fetchData).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('disabled when query is last in list', () => {
|
||||
const mockedProps2 = {
|
||||
...mockedProps,
|
||||
query: mockQueries[2],
|
||||
};
|
||||
const wrapper2 = mount(
|
||||
<QueryPreviewModal store={store} {...mockedProps2} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper2.find('[data-test="next-query"]').first().props().disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Open in SQL Lab button', () => {
|
||||
it('calls openInSqlLab prop', () => {
|
||||
const props = wrapper
|
||||
.find('[data-test="open-in-sql-lab"]')
|
||||
.first()
|
||||
.props();
|
||||
|
||||
if (typeof props.onClick === 'function') {
|
||||
props.onClick({} as MouseEvent);
|
||||
}
|
||||
|
||||
expect(mockedProps.openInSqlLab).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import thunk from 'redux-thunk';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { styledMount as mount } from 'spec/helpers/theming';
|
||||
import Button from 'src/components/Button';
|
||||
import Modal from 'src/components/Modal';
|
||||
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
|
||||
import { act } from 'spec/helpers/testing-library';
|
||||
import SavedQueryPreviewModal from './SavedQueryPreviewModal';
|
||||
|
||||
// store needed for withToasts(DatabaseList)
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
const mockqueries = [...new Array(3)].map((_, i) => ({
|
||||
created_by: {
|
||||
id: i,
|
||||
first_name: `user`,
|
||||
last_name: `${i}`,
|
||||
},
|
||||
created_on: `${i}-2020`,
|
||||
database: {
|
||||
database_name: `db ${i}`,
|
||||
id: i,
|
||||
},
|
||||
changed_on_delta_humanized: '1 day ago',
|
||||
db_id: i,
|
||||
description: `SQL for ${i}`,
|
||||
id: i,
|
||||
label: `query ${i}`,
|
||||
schema: 'public',
|
||||
sql: `SELECT ${i} FROM table`,
|
||||
sql_tables: [
|
||||
{
|
||||
catalog: null,
|
||||
schema: null,
|
||||
table: `${i}`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const mockedProps = {
|
||||
fetchData: jest.fn(() => {}),
|
||||
openInSqlLab: jest.fn(() => {}),
|
||||
onHide: () => {},
|
||||
queries: mockqueries,
|
||||
savedQuery: mockqueries[1],
|
||||
show: true,
|
||||
};
|
||||
|
||||
const FETCH_SAVED_QUERY_ENDPOINT = 'glob:*/api/v1/saved_query/*';
|
||||
const SAVED_QUERY_PAYLOAD = { result: mockqueries[1] };
|
||||
|
||||
fetchMock.get(FETCH_SAVED_QUERY_ENDPOINT, SAVED_QUERY_PAYLOAD);
|
||||
|
||||
async function mountAndWait(props = mockedProps) {
|
||||
const mounted = mount(<SavedQueryPreviewModal store={store} {...props} />);
|
||||
await waitForComponentToPaint(mounted);
|
||||
|
||||
return mounted;
|
||||
}
|
||||
|
||||
describe('SavedQueryPreviewModal', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeAll(async () => {
|
||||
wrapper = await mountAndWait();
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
expect(wrapper.find(SavedQueryPreviewModal)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a Modal', () => {
|
||||
expect(wrapper.find(Modal)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders sql from saved query', () => {
|
||||
expect(wrapper.find('pre').text()).toEqual('SELECT 1 FROM table');
|
||||
});
|
||||
|
||||
it('renders buttons with correct text', () => {
|
||||
expect(wrapper.find(Button).contains('Previous')).toBe(true);
|
||||
expect(wrapper.find(Button).contains('Next')).toBe(true);
|
||||
expect(wrapper.find(Button).contains('Open in SQL Lab')).toBe(true);
|
||||
});
|
||||
|
||||
it('handle next save query', () => {
|
||||
const button = wrapper.find('button[data-test="next-saved-query"]');
|
||||
expect(button.props().disabled).toBe(false);
|
||||
act(() => {
|
||||
button.props().onClick(false);
|
||||
});
|
||||
expect(mockedProps.fetchData).toHaveBeenCalled();
|
||||
expect(mockedProps.fetchData.mock.calls[0][0]).toEqual(2);
|
||||
});
|
||||
|
||||
it('handle previous save query', () => {
|
||||
const button = wrapper
|
||||
.find('[data-test="previous-saved-query"]')
|
||||
.find(Button);
|
||||
expect(button.props().disabled).toBe(false);
|
||||
act(() => {
|
||||
button.props().onClick(true);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(mockedProps.fetchData).toHaveBeenCalled();
|
||||
expect(mockedProps.fetchData.mock.calls[0][0]).toEqual(2);
|
||||
});
|
||||
|
||||
it('handle open in sql lab', async () => {
|
||||
act(() => {
|
||||
wrapper.find('[data-test="open-in-sql-lab"]').first().props().onClick({});
|
||||
});
|
||||
expect(mockedProps.openInSqlLab).toHaveBeenCalled();
|
||||
expect(mockedProps.openInSqlLab.mock.calls[0][0]).toEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -21,14 +21,22 @@ import Select from 'src/components/Select/Select';
|
||||
import { Input } from 'src/components/Input';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { FC } from 'react';
|
||||
import { FormattedPermission, UserObject } from './types';
|
||||
import { GroupObject } from 'src/pages/GroupsList';
|
||||
import AsyncSelect from 'src/components/Select/AsyncSelect';
|
||||
import { FormattedPermission } from './types';
|
||||
import { fetchUserOptions } from '../groups/utils';
|
||||
|
||||
interface PermissionsFieldProps {
|
||||
permissions: FormattedPermission[];
|
||||
}
|
||||
|
||||
interface GroupsFieldProps {
|
||||
groups: GroupObject[];
|
||||
}
|
||||
|
||||
interface UsersFieldProps {
|
||||
users: UserObject[];
|
||||
addDangerToast: (msg: string) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const RoleNameField = () => (
|
||||
@@ -58,13 +66,28 @@ export const PermissionsField: FC<PermissionsFieldProps> = ({
|
||||
</FormItem>
|
||||
);
|
||||
|
||||
export const UsersField: FC<UsersFieldProps> = ({ users }) => (
|
||||
export const UsersField = ({ addDangerToast, loading }: UsersFieldProps) => (
|
||||
<FormItem name="roleUsers" label={t('Users')}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
<AsyncSelect
|
||||
name="roleUsers"
|
||||
options={users.map(user => ({ label: user.username, value: user.id }))}
|
||||
data-test="users-select"
|
||||
mode="multiple"
|
||||
placeholder={t('Select users')}
|
||||
options={(filterValue, page, pageSize) =>
|
||||
fetchUserOptions(filterValue, page, pageSize, addDangerToast)
|
||||
}
|
||||
loading={loading}
|
||||
data-test="roles-select"
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
|
||||
export const GroupsField: FC<GroupsFieldProps> = ({ groups }) => (
|
||||
<FormItem name="roleGroups" label={t('Groups')}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
name="roleGroups"
|
||||
options={groups.map(group => ({ label: group.name, value: group.id }))}
|
||||
data-test="groups-select"
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
|
||||
@@ -43,9 +43,11 @@ function RoleListAddModal({
|
||||
await updateRolePermissions(roleResponse.id, values.rolePermissions);
|
||||
}
|
||||
|
||||
addSuccessToast(t('Role was successfully created!'));
|
||||
addSuccessToast(t('The role has been created successfully.'));
|
||||
} catch (err) {
|
||||
addDangerToast(t('Error while adding role!'));
|
||||
addDangerToast(
|
||||
t('There was an error creating the role. Please, try again.'),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,6 +45,7 @@ describe('RoleListDuplicateModal', () => {
|
||||
name: 'Admin',
|
||||
permission_ids: [10, 20],
|
||||
user_ids: [1],
|
||||
group_ids: [],
|
||||
};
|
||||
|
||||
const mockProps = {
|
||||
|
||||
@@ -45,9 +45,11 @@ function RoleListDuplicateModal({
|
||||
if (permission_ids.length > 0) {
|
||||
await updateRolePermissions(roleResponse.id, permission_ids);
|
||||
}
|
||||
addSuccessToast(t('Role was successfully duplicated!'));
|
||||
addSuccessToast(t('The role has been duplicated successfully.'));
|
||||
} catch (err) {
|
||||
addDangerToast(t('Error while duplicating role!'));
|
||||
addDangerToast(
|
||||
t('There was an error duplicating the role. Please, try again.'),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import RoleListEditModal from './RoleListEditModal';
|
||||
import {
|
||||
updateRoleName,
|
||||
@@ -44,12 +45,24 @@ jest.mock('src/components/MessageToasts/withToasts', () => ({
|
||||
useToasts: () => mockToasts,
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/core', () => {
|
||||
const original = jest.requireActual('@superset-ui/core');
|
||||
return {
|
||||
...original,
|
||||
SupersetClient: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
t: (str: string) => str,
|
||||
};
|
||||
});
|
||||
|
||||
describe('RoleListEditModal', () => {
|
||||
const mockRole = {
|
||||
id: 1,
|
||||
name: 'Admin',
|
||||
permission_ids: [10, 20],
|
||||
user_ids: [5, 7],
|
||||
group_ids: [1, 2],
|
||||
};
|
||||
|
||||
const mockPermissions = [
|
||||
@@ -74,6 +87,17 @@ describe('RoleListEditModal', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const mockGroups = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Group A',
|
||||
label: 'Group A',
|
||||
description: 'Description A',
|
||||
roles: [],
|
||||
users: [],
|
||||
},
|
||||
];
|
||||
|
||||
const mockProps = {
|
||||
role: mockRole,
|
||||
show: true,
|
||||
@@ -81,6 +105,7 @@ describe('RoleListEditModal', () => {
|
||||
onSave: jest.fn(),
|
||||
permissions: mockPermissions,
|
||||
users: mockUsers,
|
||||
groups: mockGroups,
|
||||
};
|
||||
|
||||
it('renders modal with correct title and fields', () => {
|
||||
@@ -114,6 +139,36 @@ describe('RoleListEditModal', () => {
|
||||
});
|
||||
|
||||
it('calls update functions when save button is clicked', async () => {
|
||||
(SupersetClient.get as jest.Mock).mockImplementation(({ endpoint }) => {
|
||||
if (endpoint?.includes('/api/v1/security/users/')) {
|
||||
return Promise.resolve({
|
||||
json: {
|
||||
count: 2,
|
||||
result: [
|
||||
{
|
||||
id: 5,
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
username: 'johndoe',
|
||||
email: 'john@example.com',
|
||||
is_active: true,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
first_name: 'Jane',
|
||||
last_name: 'Smith',
|
||||
username: 'janesmith',
|
||||
email: 'jane@example.com',
|
||||
is_active: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({ json: { count: 0, result: [] } });
|
||||
});
|
||||
|
||||
render(<RoleListEditModal {...mockProps} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('role-name-input'), {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { t } from '@superset-ui/core';
|
||||
import Tabs from 'src/components/Tabs';
|
||||
import { RoleObject } from 'src/pages/RolesList';
|
||||
@@ -25,13 +25,22 @@ import {
|
||||
BaseModalProps,
|
||||
FormattedPermission,
|
||||
RoleForm,
|
||||
UserObject,
|
||||
} from 'src/features/roles/types';
|
||||
import { CellProps } from 'react-table';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import FormModal from 'src/components/Modal/FormModal';
|
||||
import { PermissionsField, RoleNameField, UsersField } from './RoleFormItems';
|
||||
import { GroupObject } from 'src/pages/GroupsList';
|
||||
import { FormInstance } from 'src/components';
|
||||
import { fetchPaginatedData } from 'src/utils/fetchOptions';
|
||||
import { UserObject } from 'src/pages/UsersList';
|
||||
import {
|
||||
GroupsField,
|
||||
PermissionsField,
|
||||
RoleNameField,
|
||||
UsersField,
|
||||
} from './RoleFormItems';
|
||||
import {
|
||||
updateRoleGroups,
|
||||
updateRoleName,
|
||||
updateRolePermissions,
|
||||
updateRoleUsers,
|
||||
@@ -40,7 +49,7 @@ import {
|
||||
export interface RoleListEditModalProps extends BaseModalProps {
|
||||
role: RoleObject;
|
||||
permissions: FormattedPermission[];
|
||||
users: UserObject[];
|
||||
groups: GroupObject[];
|
||||
}
|
||||
|
||||
const roleTabs = {
|
||||
@@ -85,23 +94,67 @@ function RoleListEditModal({
|
||||
role,
|
||||
onSave,
|
||||
permissions,
|
||||
users,
|
||||
groups,
|
||||
}: RoleListEditModalProps) {
|
||||
const { id, name, permission_ids, user_ids } = role;
|
||||
const { id, name, permission_ids, user_ids, group_ids } = role;
|
||||
const [activeTabKey, setActiveTabKey] = useState(roleTabs.edit.key);
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
const filteredUsers = users.filter(user =>
|
||||
user?.roles.some(role => role.id === id),
|
||||
);
|
||||
const [roleUsers, setRoleUsers] = useState<UserObject[]>([]);
|
||||
const [loadingRoleUsers, setLoadingRoleUsers] = useState(true);
|
||||
const formRef = useRef<FormInstance | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user_ids.length) {
|
||||
setRoleUsers([]);
|
||||
setLoadingRoleUsers(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const filters = [{ col: 'id', opr: 'in', value: user_ids }];
|
||||
|
||||
fetchPaginatedData({
|
||||
endpoint: `/api/v1/security/users/`,
|
||||
pageSize: 100,
|
||||
setData: setRoleUsers,
|
||||
filters,
|
||||
setLoadingState: (loading: boolean) => setLoadingRoleUsers(loading),
|
||||
loadingKey: 'roleUsers',
|
||||
addDangerToast,
|
||||
errorMessage: t('There was an error loading users.'),
|
||||
mapResult: (user: UserObject) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
}),
|
||||
});
|
||||
}, [user_ids]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadingRoleUsers && formRef.current && roleUsers.length >= 0) {
|
||||
const userOptions = roleUsers.map(user => ({
|
||||
value: user.id,
|
||||
label: user.username,
|
||||
}));
|
||||
|
||||
formRef.current.setFieldsValue({
|
||||
roleUsers: userOptions,
|
||||
});
|
||||
}
|
||||
}, [loadingRoleUsers, roleUsers]);
|
||||
|
||||
const handleFormSubmit = async (values: RoleForm) => {
|
||||
try {
|
||||
await updateRoleName(id, values.roleName);
|
||||
await updateRolePermissions(id, values.rolePermissions);
|
||||
await updateRoleUsers(id, values.roleUsers);
|
||||
addSuccessToast(t('Role successfully updated!'));
|
||||
const userIds = values.roleUsers?.map(user => user.value) || [];
|
||||
await Promise.all([
|
||||
updateRoleName(id, values.roleName),
|
||||
updateRolePermissions(id, values.rolePermissions),
|
||||
updateRoleUsers(id, userIds),
|
||||
updateRoleGroups(id, values.roleGroups),
|
||||
]);
|
||||
addSuccessToast(t('The role has been updated successfully.'));
|
||||
} catch (err) {
|
||||
addDangerToast(t('Error while updating role!'));
|
||||
addDangerToast(
|
||||
t('There was an error updating the role. Please, try again.'),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -109,7 +162,12 @@ function RoleListEditModal({
|
||||
const initialValues = {
|
||||
roleName: name,
|
||||
rolePermissions: permission_ids,
|
||||
roleUsers: user_ids,
|
||||
roleUsers:
|
||||
roleUsers?.map(user => ({
|
||||
value: user.id,
|
||||
label: user.username,
|
||||
})) || [],
|
||||
roleGroups: group_ids,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -123,29 +181,39 @@ function RoleListEditModal({
|
||||
bodyStyle={{ height: '400px' }}
|
||||
requiredFields={['roleName']}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeTabKey}
|
||||
onChange={activeKey => setActiveTabKey(activeKey)}
|
||||
>
|
||||
<Tabs.TabPane
|
||||
tab={roleTabs.edit.name}
|
||||
key={roleTabs.edit.key}
|
||||
forceRender
|
||||
>
|
||||
<>
|
||||
<RoleNameField />
|
||||
<PermissionsField permissions={permissions} />
|
||||
<UsersField users={users} />
|
||||
</>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={roleTabs.users.name} key={roleTabs.users.key}>
|
||||
<TableView
|
||||
columns={userColumns}
|
||||
data={filteredUsers}
|
||||
emptyWrapperType={EmptyWrapperType.Small}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
{(form: FormInstance) => {
|
||||
formRef.current = form;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={activeTabKey}
|
||||
onChange={activeKey => setActiveTabKey(activeKey)}
|
||||
>
|
||||
<Tabs.TabPane
|
||||
tab={roleTabs.edit.name}
|
||||
key={roleTabs.edit.key}
|
||||
forceRender
|
||||
>
|
||||
<>
|
||||
<RoleNameField />
|
||||
<PermissionsField permissions={permissions} />
|
||||
<UsersField
|
||||
addDangerToast={addDangerToast}
|
||||
loading={loadingRoleUsers}
|
||||
/>
|
||||
<GroupsField groups={groups} />
|
||||
</>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={roleTabs.users.name} key={roleTabs.users.key}>
|
||||
<TableView
|
||||
columns={userColumns}
|
||||
data={roleUsers}
|
||||
emptyWrapperType={EmptyWrapperType.Small}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
);
|
||||
}}
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ export type UserObject = {
|
||||
roles: Array<RoleInfo>;
|
||||
};
|
||||
|
||||
export type SelectOption = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type RoleInfo = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -56,7 +61,8 @@ export type RoleInfo = {
|
||||
export type RoleForm = {
|
||||
roleName: string;
|
||||
rolePermissions: number[];
|
||||
roleUsers: number[];
|
||||
roleUsers: SelectOption[];
|
||||
roleGroups: number[];
|
||||
};
|
||||
|
||||
export interface BaseModalProps {
|
||||
|
||||
@@ -40,6 +40,12 @@ export const updateRoleUsers = async (roleId: number, userIds: number[]) =>
|
||||
jsonPayload: { user_ids: userIds },
|
||||
});
|
||||
|
||||
export const updateRoleGroups = async (roleId: number, groupIds: number[]) =>
|
||||
SupersetClient.put({
|
||||
endpoint: `/api/v1/security/roles/${roleId}/groups`,
|
||||
jsonPayload: { group_ids: groupIds },
|
||||
});
|
||||
|
||||
export const updateRoleName = async (roleId: number, name: string) =>
|
||||
SupersetClient.put({
|
||||
endpoint: `/api/v1/security/roles/${roleId}`,
|
||||
|
||||
@@ -179,20 +179,6 @@ export function addTag(
|
||||
.catch(response => error(response));
|
||||
}
|
||||
|
||||
export function fetchObjects(
|
||||
{ tags = '', types }: { tags: string; types: string | null },
|
||||
callback: (json: JsonObject) => void,
|
||||
error: (response: Response) => void,
|
||||
) {
|
||||
let url = `/api/v1/tag/get_objects/?tags=${tags}`;
|
||||
if (types) {
|
||||
url += `&types=${types}`;
|
||||
}
|
||||
SupersetClient.get({ endpoint: url })
|
||||
.then(({ json }) => callback(json.result))
|
||||
.catch(response => error(response));
|
||||
}
|
||||
|
||||
export function fetchObjectsByTagIds(
|
||||
{ tagIds = [], types }: { tagIds: number[] | string; types: string | null },
|
||||
callback: (json: JsonObject) => void,
|
||||
|
||||
150
superset-frontend/src/features/userInfo/UserInfoModal.tsx
Normal file
150
superset-frontend/src/features/userInfo/UserInfoModal.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { SupersetClient, t } from '@superset-ui/core';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import FormModal from 'src/components/Modal/FormModal';
|
||||
import { FormItem } from 'src/components/Form';
|
||||
import { Input } from 'src/components/Input';
|
||||
import { User } from 'src/types/bootstrapTypes';
|
||||
import { BaseUserListModalProps, FormValues } from '../users/types';
|
||||
|
||||
export interface UserInfoModalProps extends BaseUserListModalProps {
|
||||
isEditMode?: boolean;
|
||||
user?: User;
|
||||
}
|
||||
|
||||
function UserInfoModal({
|
||||
show,
|
||||
onHide,
|
||||
onSave,
|
||||
isEditMode,
|
||||
user,
|
||||
}: UserInfoModalProps) {
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
|
||||
const requiredFields = isEditMode
|
||||
? ['first_name', 'last_name']
|
||||
: ['password', 'confirm_password'];
|
||||
const initialValues = isEditMode
|
||||
? {
|
||||
first_name: user?.firstName,
|
||||
last_name: user?.lastName,
|
||||
}
|
||||
: {};
|
||||
const handleFormSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const { confirm_password, ...payload } = values;
|
||||
await SupersetClient.put({
|
||||
endpoint: `/api/v1/me/`,
|
||||
jsonPayload: { ...payload },
|
||||
});
|
||||
addSuccessToast(
|
||||
isEditMode
|
||||
? t('The user was updated successfully')
|
||||
: t('The password reset was successfull'),
|
||||
);
|
||||
onSave();
|
||||
} catch (error) {
|
||||
addDangerToast(t('Something went wrong while saving the user info'));
|
||||
}
|
||||
};
|
||||
|
||||
const EditModeFields = () => (
|
||||
<>
|
||||
<FormItem
|
||||
name="first_name"
|
||||
label={t('First name')}
|
||||
rules={[{ required: true, message: t('First name is required') }]}
|
||||
>
|
||||
<Input
|
||||
name="first_name"
|
||||
placeholder={t("Enter the user's first name")}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
name="last_name"
|
||||
label={t('Last name')}
|
||||
rules={[{ required: true, message: t('Last name is required') }]}
|
||||
>
|
||||
<Input name="last_name" placeholder={t("Enter the user's last name")} />
|
||||
</FormItem>
|
||||
</>
|
||||
);
|
||||
|
||||
const ResetPasswordFields = () => (
|
||||
<>
|
||||
<FormItem
|
||||
name="password"
|
||||
label={t('Password')}
|
||||
rules={[{ required: true, message: t('Password is required') }]}
|
||||
>
|
||||
<Input.Password
|
||||
name="password"
|
||||
placeholder="Enter the user's password"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
name="confirm_password"
|
||||
label={t('Confirm Password')}
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('Please confirm your password'),
|
||||
},
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(t('Passwords do not match!')));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
name="confirm_password"
|
||||
placeholder={t("Confirm the user's password")}
|
||||
/>
|
||||
</FormItem>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
title={isEditMode ? t('Edit user') : t('Reset password')}
|
||||
onSave={onSave}
|
||||
formSubmitHandler={handleFormSubmit}
|
||||
requiredFields={requiredFields}
|
||||
initialValues={initialValues}
|
||||
>
|
||||
{isEditMode ? <EditModeFields /> : <ResetPasswordFields />}
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
export const UserInfoResetPasswordModal = (
|
||||
props: Omit<UserInfoModalProps, 'isEditMode' | 'user'>,
|
||||
) => <UserInfoModal {...props} isEditMode={false} />;
|
||||
|
||||
export const UserInfoEditModal = (
|
||||
props: Omit<UserInfoModalProps, 'isEditMode'> & { user: User },
|
||||
) => <UserInfoModal {...props} isEditMode />;
|
||||
@@ -23,15 +23,17 @@ import { FormItem } from 'src/components/Form';
|
||||
import { Input } from 'src/components/Input';
|
||||
import Checkbox from 'src/components/Checkbox';
|
||||
import Select from 'src/components/Select/Select';
|
||||
import { Role, UserObject } from 'src/pages/UsersList';
|
||||
import { Group, Role, UserObject } from 'src/pages/UsersList';
|
||||
import { FormInstance } from 'src/components';
|
||||
import { Actions } from 'src/constants';
|
||||
import { BaseUserListModalProps, FormValues } from './types';
|
||||
import { createUser, updateUser } from './utils';
|
||||
import { createUser, updateUser, atLeastOneRoleOrGroup } from './utils';
|
||||
|
||||
export interface UserModalProps extends BaseUserListModalProps {
|
||||
roles: Role[];
|
||||
isEditMode?: boolean;
|
||||
user?: UserObject;
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
function UserListModal({
|
||||
@@ -41,14 +43,18 @@ function UserListModal({
|
||||
roles,
|
||||
isEditMode = false,
|
||||
user,
|
||||
groups,
|
||||
}: UserModalProps) {
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
const handleFormSubmit = async (values: FormValues) => {
|
||||
const handleError = async (err: any, action: 'create' | 'update') => {
|
||||
const handleError = async (
|
||||
err: any,
|
||||
action: Actions.CREATE | Actions.UPDATE,
|
||||
) => {
|
||||
let errorMessage =
|
||||
action === 'create'
|
||||
? t('Error while adding user!')
|
||||
: t('Error while updating user!');
|
||||
action === Actions.CREATE
|
||||
? t('There was an error creating the user. Please, try again.')
|
||||
: t('There was an error updating the user. Please, try again.');
|
||||
|
||||
if (err.status === 422) {
|
||||
const errorData = await err.json();
|
||||
@@ -61,7 +67,7 @@ function UserListModal({
|
||||
);
|
||||
} else if (detail.includes('ab_user_email_key')) {
|
||||
errorMessage = t(
|
||||
'This email is already associated with an account.',
|
||||
'This email is already associated with an account. Please choose another one.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -77,35 +83,35 @@ function UserListModal({
|
||||
}
|
||||
try {
|
||||
await updateUser(user.id, values);
|
||||
addSuccessToast(t('User was successfully updated!'));
|
||||
addSuccessToast(t('The user has been updated successfully.'));
|
||||
} catch (err) {
|
||||
await handleError(err, 'update');
|
||||
await handleError(err, Actions.UPDATE);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await createUser(values);
|
||||
addSuccessToast(t('User was successfully created!'));
|
||||
addSuccessToast(t('The group has been created successfully.'));
|
||||
} catch (err) {
|
||||
await handleError(err, 'create');
|
||||
await handleError(err, Actions.CREATE);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const requiredFields = isEditMode
|
||||
? ['first_name', 'last_name', 'username', 'email', 'roles']
|
||||
? ['first_name', 'last_name', 'username', 'email']
|
||||
: [
|
||||
'first_name',
|
||||
'last_name',
|
||||
'username',
|
||||
'email',
|
||||
'password',
|
||||
'roles',
|
||||
'confirmPassword',
|
||||
];
|
||||
|
||||
const initialValues = {
|
||||
...user,
|
||||
roles: user?.roles.map(role => role.id) || [],
|
||||
roles: user?.roles?.map(role => role.id) || [],
|
||||
groups: user?.groups?.map(group => group.id) || [],
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -177,7 +183,8 @@ function UserListModal({
|
||||
<FormItem
|
||||
name="roles"
|
||||
label={t('Roles')}
|
||||
rules={[{ required: true, message: t('Role is required') }]}
|
||||
dependencies={['groups']}
|
||||
rules={[atLeastOneRoleOrGroup('groups')]}
|
||||
>
|
||||
<Select
|
||||
name="roles"
|
||||
@@ -192,7 +199,25 @@ function UserListModal({
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
name="groups"
|
||||
label={t('Groups')}
|
||||
dependencies={['roles']}
|
||||
rules={[atLeastOneRoleOrGroup('roles')]}
|
||||
>
|
||||
<Select
|
||||
name="groups"
|
||||
mode="multiple"
|
||||
placeholder={t('Select groups')}
|
||||
options={groups.map(group => ({
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
}))}
|
||||
getPopupContainer={trigger =>
|
||||
trigger.closest('.antd5-modal-content')
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
{!isEditMode && (
|
||||
<>
|
||||
<FormItem
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { SupersetClient, t } from '@superset-ui/core';
|
||||
import { SelectOption } from 'src/components/ListView';
|
||||
import { FormValues } from './types';
|
||||
|
||||
export const createUser = async (values: FormValues) => {
|
||||
@@ -41,3 +42,22 @@ export const deleteUser = async (userId: number) =>
|
||||
SupersetClient.delete({
|
||||
endpoint: `/api/v1/security/users/${userId}`,
|
||||
});
|
||||
|
||||
export const atLeastOneRoleOrGroup =
|
||||
(fieldToCheck: 'roles' | 'groups') =>
|
||||
({
|
||||
getFieldValue,
|
||||
}: {
|
||||
getFieldValue: (field: string) => Array<SelectOption>;
|
||||
}) => ({
|
||||
validator(_: Object, value: Array<SelectOption>) {
|
||||
const current = value || [];
|
||||
const other = getFieldValue(fieldToCheck) || [];
|
||||
if (current.length === 0 && other.length === 0) {
|
||||
return Promise.reject(
|
||||
new Error(t('Please select at least one role or group')),
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
282
superset-frontend/src/pages/ActionLog/index.tsx
Normal file
282
superset-frontend/src/pages/ActionLog/index.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { t, css } from '@superset-ui/core';
|
||||
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
|
||||
import { useListViewResource } from 'src/views/CRUD/hooks';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import ListView, { Filters, FilterOperator } from 'src/components/ListView';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Typography } from 'antd-v5';
|
||||
import { fetchUserOptions } from 'src/features/groups/utils';
|
||||
|
||||
export type ActionLogObject = {
|
||||
user: {
|
||||
username: string;
|
||||
};
|
||||
action: string;
|
||||
dttm: string | null;
|
||||
dashboard_id?: number;
|
||||
slice_id?: number;
|
||||
json?: string;
|
||||
duration_ms?: number;
|
||||
referrer?: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
function ActionLogList() {
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
const initialSort = [{ id: 'dttm', desc: true }];
|
||||
const subMenuButtons: SubMenuProps['buttons'] = [];
|
||||
|
||||
const {
|
||||
state: {
|
||||
loading,
|
||||
resourceCount: LogsCount,
|
||||
resourceCollection: Logs,
|
||||
bulkSelectEnabled,
|
||||
},
|
||||
fetchData,
|
||||
refreshData,
|
||||
toggleBulkSelect,
|
||||
} = useListViewResource<ActionLogObject>(
|
||||
'log',
|
||||
t('Log'),
|
||||
addDangerToast,
|
||||
false,
|
||||
);
|
||||
const filters: Filters = useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: t('Users'),
|
||||
key: 'user',
|
||||
id: 'user',
|
||||
input: 'select',
|
||||
operator: FilterOperator.RelationOneMany,
|
||||
unfilteredLabel: t('All'),
|
||||
fetchSelects: async (filterValue, page, pageSize) =>
|
||||
fetchUserOptions(filterValue, page, pageSize, addDangerToast),
|
||||
},
|
||||
{
|
||||
Header: t('Dashboard Id'),
|
||||
key: 'dashboard_id',
|
||||
id: 'dashboard_id',
|
||||
input: 'search',
|
||||
operator: FilterOperator.Equals,
|
||||
},
|
||||
{
|
||||
Header: t('Slice Id'),
|
||||
key: 'slice_id',
|
||||
id: 'slice_id',
|
||||
input: 'search',
|
||||
operator: FilterOperator.Equals,
|
||||
},
|
||||
{
|
||||
Header: t('Action'),
|
||||
key: 'action',
|
||||
id: 'action',
|
||||
input: 'search',
|
||||
operator: FilterOperator.Contains,
|
||||
},
|
||||
{
|
||||
Header: t('JSON'),
|
||||
key: 'json',
|
||||
id: 'json',
|
||||
input: 'search',
|
||||
operator: FilterOperator.Contains,
|
||||
},
|
||||
{
|
||||
Header: t('dttm'),
|
||||
key: 'dttm',
|
||||
id: 'dttm',
|
||||
input: 'datetime_range',
|
||||
operator: FilterOperator.Between,
|
||||
dateFilterValueType: 'iso',
|
||||
},
|
||||
{
|
||||
Header: t('Referrer'),
|
||||
key: 'referrer',
|
||||
id: 'referrer',
|
||||
input: 'search',
|
||||
operator: FilterOperator.Equals,
|
||||
},
|
||||
{
|
||||
Header: t('Duration Ms'),
|
||||
key: 'duration_ms',
|
||||
id: 'duration_ms',
|
||||
input: 'search',
|
||||
operator: FilterOperator.Equals,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessor: 'action',
|
||||
Header: t('Action'),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { action },
|
||||
},
|
||||
}: any) => <span>{action}</span>,
|
||||
},
|
||||
{
|
||||
accessor: 'user',
|
||||
Header: t('User'),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { user },
|
||||
},
|
||||
}: any) => <span>{user?.username}</span>,
|
||||
},
|
||||
|
||||
{
|
||||
accessor: 'duration_ms',
|
||||
Header: t('Duration Ms'),
|
||||
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { duration_ms },
|
||||
},
|
||||
}: any) => <span>{duration_ms}</span>,
|
||||
},
|
||||
{
|
||||
accessor: 'dashboard_id',
|
||||
Header: t('Dashboard Id'),
|
||||
hidden: false,
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { dashboard_id },
|
||||
},
|
||||
}: any) => <span>{dashboard_id}</span>,
|
||||
},
|
||||
{
|
||||
accessor: 'slice_id',
|
||||
Header: t('Slice Id'),
|
||||
hidden: false,
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { slice_id },
|
||||
},
|
||||
}: any) => <span>{slice_id}</span>,
|
||||
},
|
||||
{
|
||||
accessor: 'json',
|
||||
Header: t('JSON'),
|
||||
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { json },
|
||||
},
|
||||
}: any) => (
|
||||
<Typography.Text
|
||||
css={css`
|
||||
.antd5-typography-copy {
|
||||
visibility: hidden;
|
||||
}
|
||||
&:hover .antd5-typography-copy {
|
||||
visibility: visible;
|
||||
}
|
||||
`}
|
||||
copyable={!!json}
|
||||
ellipsis={{
|
||||
tooltip: { styles: { root: { maxWidth: '900px' } }, title: json },
|
||||
}}
|
||||
>
|
||||
{json}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
accessor: 'referrer',
|
||||
Header: t('Referrer'),
|
||||
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { referrer },
|
||||
},
|
||||
}: any) => (
|
||||
<Typography.Text
|
||||
css={css`
|
||||
.antd5-typography-copy {
|
||||
visibility: hidden;
|
||||
}
|
||||
&:hover .antd5-typography-copy {
|
||||
visibility: visible;
|
||||
}
|
||||
`}
|
||||
copyable={!!referrer}
|
||||
ellipsis={{
|
||||
tooltip: {
|
||||
styles: { root: { maxWidth: '580px' } },
|
||||
title: referrer,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{referrer}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: 'dttm',
|
||||
Header: t('Dttm'),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { dttm },
|
||||
},
|
||||
}: any) => <span>{dttm}</span>,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const emptyState = {
|
||||
title: t('No Logs yet'),
|
||||
image: 'filter-results.svg',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubMenu name={t('Action Logs')} buttons={subMenuButtons} />
|
||||
<ListView<ActionLogObject>
|
||||
className="action-log-view"
|
||||
columns={columns}
|
||||
count={LogsCount}
|
||||
data={Logs}
|
||||
fetchData={fetchData}
|
||||
filters={filters}
|
||||
initialSort={initialSort}
|
||||
loading={loading}
|
||||
pageSize={PAGE_SIZE}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
disableBulkSelect={toggleBulkSelect}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
emptyState={emptyState}
|
||||
refreshData={refreshData}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActionLogList;
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import thunk from 'redux-thunk';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { Provider } from 'react-redux';
|
||||
import { styledMount as mount } from 'spec/helpers/theming';
|
||||
|
||||
import AnnotationList from 'src/pages/AnnotationList';
|
||||
import DeleteModal from 'src/components/DeleteModal';
|
||||
import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
|
||||
import ListView from 'src/components/ListView';
|
||||
import SubMenu from 'src/features/home/SubMenu';
|
||||
|
||||
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
|
||||
import { act } from 'spec/helpers/testing-library';
|
||||
|
||||
// store needed for withToasts(AnnotationList)
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
const annotationsEndpoint = 'glob:*/api/v1/annotation_layer/*/annotation*';
|
||||
const annotationLayerEndpoint = 'glob:*/api/v1/annotation_layer/*';
|
||||
|
||||
fetchMock.delete(annotationsEndpoint, {});
|
||||
|
||||
const mockannotations = [...new Array(3)].map((_, i) => ({
|
||||
changed_on_delta_humanized: `${i} day(s) ago`,
|
||||
created_by: {
|
||||
first_name: `user`,
|
||||
id: i,
|
||||
},
|
||||
changed_by: {
|
||||
first_name: `user`,
|
||||
id: i,
|
||||
},
|
||||
end_dttm: new Date().toISOString,
|
||||
id: i,
|
||||
long_descr: `annotation ${i} description`,
|
||||
short_descr: `annotation ${i} label`,
|
||||
start_dttm: new Date().toISOString,
|
||||
}));
|
||||
|
||||
fetchMock.get(annotationsEndpoint, {
|
||||
ids: [2, 0, 1],
|
||||
result: mockannotations,
|
||||
count: 3,
|
||||
});
|
||||
|
||||
fetchMock.get(annotationLayerEndpoint, {
|
||||
id: 1,
|
||||
result: { descr: 'annotations test 0', name: 'Test 0' },
|
||||
});
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
|
||||
useParams: () => ({ annotationLayerId: '1' }),
|
||||
}));
|
||||
|
||||
async function mountAndWait(props) {
|
||||
const mounted = mount(
|
||||
<Provider store={store}>
|
||||
<AnnotationList {...props} />
|
||||
</Provider>,
|
||||
);
|
||||
await waitForComponentToPaint(mounted);
|
||||
|
||||
return mounted;
|
||||
}
|
||||
|
||||
describe('AnnotationList', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeAll(async () => {
|
||||
wrapper = await mountAndWait();
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
expect(wrapper.find(AnnotationList)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a SubMenu', () => {
|
||||
expect(wrapper.find(SubMenu)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a ListView', () => {
|
||||
expect(wrapper.find(ListView)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fetches annotation layer', () => {
|
||||
const callsQ = fetchMock.calls(/annotation_layer\/1/);
|
||||
expect(callsQ).toHaveLength(2);
|
||||
expect(callsQ[1][0]).toMatchInlineSnapshot(
|
||||
`"http://localhost/api/v1/annotation_layer/1"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches annotations', () => {
|
||||
const callsQ = fetchMock.calls(/annotation_layer\/1\/annotation/);
|
||||
expect(callsQ).toHaveLength(1);
|
||||
expect(callsQ[0][0]).toMatchInlineSnapshot(
|
||||
`"http://localhost/api/v1/annotation_layer/1/annotation/?q=(order_column:short_descr,order_direction:desc,page:0,page_size:25)"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a DeleteModal', () => {
|
||||
expect(wrapper.find(DeleteModal)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('deletes', async () => {
|
||||
act(() => {
|
||||
wrapper.find('[data-test="delete-action"]').first().props().onClick();
|
||||
});
|
||||
await waitForComponentToPaint(wrapper);
|
||||
|
||||
expect(
|
||||
wrapper.find(DeleteModal).first().props().description,
|
||||
).toMatchInlineSnapshot(
|
||||
`"Are you sure you want to delete annotation 0 label?"`,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
wrapper
|
||||
.find('#delete')
|
||||
.first()
|
||||
.props()
|
||||
.onChange({ target: { value: 'DELETE' } });
|
||||
});
|
||||
await waitForComponentToPaint(wrapper);
|
||||
act(() => {
|
||||
wrapper.find('button').last().props().onClick();
|
||||
});
|
||||
await waitForComponentToPaint(wrapper);
|
||||
});
|
||||
|
||||
it('shows/hides bulk actions when bulk actions is clicked', async () => {
|
||||
const button = wrapper.find('[data-test="annotation-bulk-select"]').first();
|
||||
act(() => {
|
||||
button.props().onClick();
|
||||
});
|
||||
await waitForComponentToPaint(wrapper);
|
||||
expect(wrapper.find(IndeterminateCheckbox)).toHaveLength(
|
||||
mockannotations.length + 1, // 1 for each row and 1 for select all
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,248 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.src/pages/DatabaseList/DatabaseList.test.jsx
|
||||
*/
|
||||
import thunk from 'redux-thunk';
|
||||
import * as reactRedux from 'react-redux';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { Provider } from 'react-redux';
|
||||
import { styledMount as mount } from 'spec/helpers/theming';
|
||||
|
||||
import DatabaseList from 'src/pages/DatabaseList';
|
||||
import DatabaseModal from 'src/features/databases/DatabaseModal';
|
||||
import DeleteModal from 'src/components/DeleteModal';
|
||||
import SubMenu from 'src/features/home/SubMenu';
|
||||
import ListView from 'src/components/ListView';
|
||||
import Filters from 'src/components/ListView/Filters';
|
||||
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
|
||||
import { act } from 'spec/helpers/testing-library';
|
||||
|
||||
// store needed for withToasts(DatabaseList)
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
const databasesInfoEndpoint = 'glob:*/api/v1/database/_info*';
|
||||
const databasesEndpoint = 'glob:*/api/v1/database/?*';
|
||||
const databaseEndpoint = 'glob:*/api/v1/database/*';
|
||||
const databaseRelatedEndpoint = 'glob:*/api/v1/database/*/related_objects*';
|
||||
|
||||
const mockdatabases = [...new Array(3)].map((_, i) => ({
|
||||
changed_by: {
|
||||
first_name: `user`,
|
||||
last_name: `${i}`,
|
||||
},
|
||||
database_name: `db ${i}`,
|
||||
backend: 'postgresql',
|
||||
allow_run_async: true,
|
||||
allow_dml: false,
|
||||
allow_file_upload: true,
|
||||
expose_in_sqllab: false,
|
||||
changed_on_delta_humanized: `${i} day(s) ago`,
|
||||
changed_on: new Date().toISOString,
|
||||
id: i,
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
fetchMock.get(databasesInfoEndpoint, {
|
||||
permissions: ['can_write'],
|
||||
});
|
||||
fetchMock.get(databasesEndpoint, {
|
||||
result: mockdatabases,
|
||||
database_count: 3,
|
||||
});
|
||||
|
||||
fetchMock.delete(databaseEndpoint, {});
|
||||
fetchMock.get(databaseRelatedEndpoint, {
|
||||
charts: {
|
||||
count: 0,
|
||||
result: [],
|
||||
},
|
||||
dashboards: {
|
||||
count: 0,
|
||||
result: [],
|
||||
},
|
||||
sqllab_tab_states: {
|
||||
count: 0,
|
||||
result: [],
|
||||
},
|
||||
});
|
||||
|
||||
fetchMock.get(
|
||||
'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))',
|
||||
{},
|
||||
);
|
||||
|
||||
const useSelectorMock = jest.spyOn(reactRedux, 'useSelector');
|
||||
const userSelectorMock = jest.spyOn(reactRedux, 'useSelector');
|
||||
|
||||
describe('Admin DatabaseList', () => {
|
||||
useSelectorMock.mockReturnValue({
|
||||
CSV_EXTENSIONS: ['csv'],
|
||||
EXCEL_EXTENSIONS: ['xls', 'xlsx'],
|
||||
COLUMNAR_EXTENSIONS: ['parquet', 'zip'],
|
||||
ALLOWED_EXTENSIONS: ['parquet', 'zip', 'xls', 'xlsx', 'csv'],
|
||||
});
|
||||
userSelectorMock.mockReturnValue({
|
||||
createdOn: '2021-04-27T18:12:38.952304',
|
||||
email: 'admin',
|
||||
firstName: 'admin',
|
||||
isActive: true,
|
||||
lastName: 'admin',
|
||||
permissions: {},
|
||||
roles: {
|
||||
Admin: [
|
||||
['can_sqllab', 'Superset'],
|
||||
['can_write', 'Dashboard'],
|
||||
['can_write', 'Chart'],
|
||||
],
|
||||
},
|
||||
userId: 1,
|
||||
username: 'admin',
|
||||
});
|
||||
|
||||
const wrapper = mount(
|
||||
<Provider store={store}>
|
||||
<DatabaseList />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
await waitForComponentToPaint(wrapper);
|
||||
});
|
||||
|
||||
test('renders', () => {
|
||||
expect(wrapper.find(DatabaseList)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('renders a SubMenu', () => {
|
||||
expect(wrapper.find(SubMenu)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('renders a SubMenu with no tabs', () => {
|
||||
expect(wrapper.find(SubMenu).props().tabs).toBeUndefined();
|
||||
});
|
||||
|
||||
test('renders a DatabaseModal', () => {
|
||||
expect(wrapper.find(DatabaseModal)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('renders a ListView', () => {
|
||||
expect(wrapper.find(ListView)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('fetches Databases', () => {
|
||||
const callsD = fetchMock.calls(/database\/\?q/);
|
||||
expect(callsD).toHaveLength(2);
|
||||
expect(callsD[0][0]).toMatchInlineSnapshot(
|
||||
`"http://localhost/api/v1/database/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`,
|
||||
);
|
||||
});
|
||||
|
||||
test('deletes', async () => {
|
||||
act(() => {
|
||||
wrapper.find('[data-test="database-delete"]').first().props().onClick();
|
||||
});
|
||||
await waitForComponentToPaint(wrapper);
|
||||
|
||||
expect(wrapper.find(DeleteModal).props().description).toMatchSnapshot();
|
||||
|
||||
act(() => {
|
||||
wrapper
|
||||
.find('#delete')
|
||||
.first()
|
||||
.props()
|
||||
.onChange({ target: { value: 'DELETE' } });
|
||||
});
|
||||
await waitForComponentToPaint(wrapper);
|
||||
act(() => {
|
||||
wrapper
|
||||
.find({ 'data-test': 'modal-confirm-button' })
|
||||
.last()
|
||||
.props()
|
||||
.onClick();
|
||||
});
|
||||
|
||||
await waitForComponentToPaint(wrapper);
|
||||
|
||||
expect(fetchMock.calls(/database\/0\/related_objects/, 'GET')).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(fetchMock.calls(/database\/0/, 'DELETE')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('filters', async () => {
|
||||
const filtersWrapper = wrapper.find(Filters);
|
||||
act(() => {
|
||||
filtersWrapper
|
||||
.find('[name="expose_in_sqllab"]')
|
||||
.first()
|
||||
.props()
|
||||
.onSelect({ label: 'Yes', value: true });
|
||||
|
||||
filtersWrapper
|
||||
.find('[name="allow_run_async"]')
|
||||
.first()
|
||||
.props()
|
||||
.onSelect({ label: 'Yes', value: false });
|
||||
|
||||
filtersWrapper
|
||||
.find('[name="database_name"]')
|
||||
.first()
|
||||
.props()
|
||||
.onSubmit('fooo');
|
||||
});
|
||||
await waitForComponentToPaint(wrapper);
|
||||
|
||||
expect(fetchMock.lastCall()[0]).toMatchInlineSnapshot(
|
||||
`"http://localhost/api/v1/database/?q=(filters:!((col:database_name,opr:ct,value:fooo),(col:expose_in_sqllab,opr:eq,value:!t),(col:allow_run_async,opr:eq,value:!f)),order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`,
|
||||
);
|
||||
});
|
||||
|
||||
test('should not render dropdown menu button if user is not admin', async () => {
|
||||
userSelectorMock.mockReturnValue({
|
||||
createdOn: '2021-05-27T18:12:38.952304',
|
||||
email: 'alpha@gmail.com',
|
||||
firstName: 'alpha',
|
||||
isActive: true,
|
||||
lastName: 'alpha',
|
||||
permissions: {},
|
||||
roles: {
|
||||
Alpha: [
|
||||
['can_sqllab', 'Superset'],
|
||||
['can_write', 'Dashboard'],
|
||||
['can_write', 'Chart'],
|
||||
],
|
||||
},
|
||||
userId: 2,
|
||||
username: 'alpha',
|
||||
});
|
||||
const newWrapper = mount(
|
||||
<Provider store={store}>
|
||||
<DatabaseList />
|
||||
</Provider>,
|
||||
);
|
||||
await waitForComponentToPaint(newWrapper);
|
||||
|
||||
expect(newWrapper.find('.dropdown-menu-links').length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Admin DatabaseList deletes 1`] = `
|
||||
<React.Fragment>
|
||||
<p>
|
||||
The database
|
||||
|
||||
<b>
|
||||
db 0
|
||||
</b>
|
||||
|
||||
is linked to 0 charts that appear on 0 dashboards and users have 0 SQL Lab tabs using this database open. Are you sure you want to continue? Deleting the database will break those objects.
|
||||
</p>
|
||||
</React.Fragment>
|
||||
`;
|
||||
@@ -1,331 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import thunk from 'redux-thunk';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { Provider } from 'react-redux';
|
||||
import { styledMount as mount } from 'spec/helpers/theming';
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { isFeatureEnabled } from '@superset-ui/core';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
|
||||
import DatasetList from 'src/pages/DatasetList';
|
||||
import ListView from 'src/components/ListView';
|
||||
import Button from 'src/components/Button';
|
||||
import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
|
||||
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
|
||||
import SubMenu from 'src/features/home/SubMenu';
|
||||
import * as reactRedux from 'react-redux';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
|
||||
// store needed for withToasts(DatasetList)
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
const datasetsInfoEndpoint = 'glob:*/api/v1/dataset/_info*';
|
||||
const datasetsOwnersEndpoint = 'glob:*/api/v1/dataset/related/owners*';
|
||||
const datasetsSchemaEndpoint = 'glob:*/api/v1/dataset/distinct/schema*';
|
||||
const datasetsDuplicateEndpoint = 'glob:*/api/v1/dataset/duplicate*';
|
||||
const databaseEndpoint = 'glob:*/api/v1/dataset/related/database*';
|
||||
const datasetsEndpoint = 'glob:*/api/v1/dataset/?*';
|
||||
|
||||
const useSelectorMock = jest.spyOn(reactRedux, 'useSelector');
|
||||
|
||||
const mockdatasets = [...new Array(3)].map((_, i) => ({
|
||||
changed_by_name: 'user',
|
||||
kind: i === 0 ? 'virtual' : 'physical', // ensure there is 1 virtual
|
||||
changed_by: 'user',
|
||||
changed_on: new Date().toISOString(),
|
||||
database_name: `db ${i}`,
|
||||
explore_url: `https://www.google.com?${i}`,
|
||||
id: i,
|
||||
schema: `schema ${i}`,
|
||||
table_name: `coolest table ${i}`,
|
||||
owners: [{ username: 'admin', userId: 1 }],
|
||||
}));
|
||||
|
||||
const mockUser = {
|
||||
userId: 1,
|
||||
};
|
||||
|
||||
fetchMock.get(datasetsInfoEndpoint, {
|
||||
permissions: ['can_read', 'can_write', 'can_duplicate'],
|
||||
});
|
||||
fetchMock.get(datasetsOwnersEndpoint, {
|
||||
result: [],
|
||||
});
|
||||
fetchMock.get(datasetsSchemaEndpoint, {
|
||||
result: [],
|
||||
});
|
||||
fetchMock.post(datasetsDuplicateEndpoint, {
|
||||
result: [],
|
||||
});
|
||||
fetchMock.get(datasetsEndpoint, {
|
||||
result: mockdatasets,
|
||||
dataset_count: 3,
|
||||
});
|
||||
fetchMock.get(databaseEndpoint, {
|
||||
result: [],
|
||||
});
|
||||
|
||||
async function mountAndWait(props: {}) {
|
||||
const mounted = mount(
|
||||
<Provider store={store}>
|
||||
<DatasetList {...props} user={mockUser} />
|
||||
</Provider>,
|
||||
);
|
||||
await waitForComponentToPaint(mounted);
|
||||
|
||||
return mounted;
|
||||
}
|
||||
|
||||
describe('DatasetList', () => {
|
||||
const mockedProps = {};
|
||||
let wrapper: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
wrapper = await mountAndWait(mockedProps);
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
expect(wrapper.find(DatasetList)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a ListView', () => {
|
||||
expect(wrapper.find(ListView)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fetches info', () => {
|
||||
const callsI = fetchMock.calls(/dataset\/_info/);
|
||||
expect(callsI).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('fetches data', () => {
|
||||
const callsD = fetchMock.calls(/dataset\/\?q/);
|
||||
expect(callsD).toHaveLength(1);
|
||||
expect(callsD[0][0]).toMatchInlineSnapshot(
|
||||
`"http://localhost/api/v1/dataset/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not fetch owner filter values on mount', async () => {
|
||||
await waitForComponentToPaint(wrapper);
|
||||
expect(fetchMock.calls(/dataset\/related\/owners/)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not fetch schema filter values on mount', async () => {
|
||||
await waitForComponentToPaint(wrapper);
|
||||
expect(fetchMock.calls(/dataset\/distinct\/schema/)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows/hides bulk actions when bulk actions is clicked', async () => {
|
||||
await waitForComponentToPaint(wrapper);
|
||||
const button = wrapper.find(Button).at(0);
|
||||
act(() => {
|
||||
button.props().onClick();
|
||||
});
|
||||
await waitForComponentToPaint(wrapper);
|
||||
expect(wrapper.find(IndeterminateCheckbox)).toHaveLength(
|
||||
mockdatasets.length + 1, // 1 for each row and 1 for select all
|
||||
);
|
||||
});
|
||||
|
||||
it('renders different bulk selected copy depending on type of row selected', async () => {
|
||||
// None selected
|
||||
const checkedEvent = { target: { checked: true } };
|
||||
const uncheckedEvent = { target: { checked: false } };
|
||||
expect(
|
||||
wrapper.find('[data-test="bulk-select-copy"]').text(),
|
||||
).toMatchInlineSnapshot(`"0 Selected"`);
|
||||
|
||||
// Virtual Selected
|
||||
act(() => {
|
||||
wrapper.find(IndeterminateCheckbox).at(1).props().onChange(checkedEvent);
|
||||
});
|
||||
await waitForComponentToPaint(wrapper);
|
||||
expect(
|
||||
wrapper.find('[data-test="bulk-select-copy"]').text(),
|
||||
).toMatchInlineSnapshot(`"1 Selected (Virtual)"`);
|
||||
|
||||
// Physical Selected
|
||||
act(() => {
|
||||
wrapper
|
||||
.find(IndeterminateCheckbox)
|
||||
.at(1)
|
||||
.props()
|
||||
.onChange(uncheckedEvent);
|
||||
wrapper.find(IndeterminateCheckbox).at(2).props().onChange(checkedEvent);
|
||||
});
|
||||
await waitForComponentToPaint(wrapper);
|
||||
expect(
|
||||
wrapper.find('[data-test="bulk-select-copy"]').text(),
|
||||
).toMatchInlineSnapshot(`"1 Selected (Physical)"`);
|
||||
|
||||
// All Selected
|
||||
act(() => {
|
||||
wrapper.find(IndeterminateCheckbox).at(0).props().onChange(checkedEvent);
|
||||
});
|
||||
await waitForComponentToPaint(wrapper);
|
||||
expect(
|
||||
wrapper.find('[data-test="bulk-select-copy"]').text(),
|
||||
).toMatchInlineSnapshot(`"3 Selected (2 Physical, 1 Virtual)"`);
|
||||
});
|
||||
|
||||
it('shows duplicate modal when duplicate action is clicked', async () => {
|
||||
await waitForComponentToPaint(wrapper);
|
||||
expect(
|
||||
wrapper.find('[data-test="duplicate-modal-input"]').exists(),
|
||||
).toBeFalsy();
|
||||
act(() => {
|
||||
wrapper
|
||||
.find('#duplicate-action-tooltip')
|
||||
.at(0)
|
||||
.find('.action-button')
|
||||
.props()
|
||||
.onClick();
|
||||
});
|
||||
await waitForComponentToPaint(wrapper);
|
||||
expect(
|
||||
wrapper.find('[data-test="duplicate-modal-input"]').exists(),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls the duplicate endpoint', async () => {
|
||||
await waitForComponentToPaint(wrapper);
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('#duplicate-action-tooltip')
|
||||
.at(0)
|
||||
.find('.action-button')
|
||||
.props()
|
||||
.onClick();
|
||||
await waitForComponentToPaint(wrapper);
|
||||
wrapper
|
||||
.find('[data-test="duplicate-modal-input"]')
|
||||
.at(0)
|
||||
.props()
|
||||
.onPressEnter();
|
||||
});
|
||||
expect(fetchMock.calls(/dataset\/duplicate/)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders a SubMenu', () => {
|
||||
expect(wrapper.find(SubMenu)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a SubMenu with no tabs', () => {
|
||||
expect(wrapper.find(SubMenu).props().tabs).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({}),
|
||||
useHistory: () => ({}),
|
||||
}));
|
||||
|
||||
describe('RTL', () => {
|
||||
async function renderAndWait() {
|
||||
const mounted = act(async () => {
|
||||
const mockedProps = {};
|
||||
render(
|
||||
<QueryParamProvider>
|
||||
<DatasetList {...mockedProps} user={mockUser} />
|
||||
</QueryParamProvider>,
|
||||
{ useRedux: true, useRouter: true },
|
||||
);
|
||||
});
|
||||
|
||||
return mounted;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
await renderAndWait();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockedIsFeatureEnabled.mockRestore();
|
||||
});
|
||||
|
||||
it('renders an "Import Dataset" tooltip under import button', async () => {
|
||||
const importButton = await screen.findByTestId('import-button');
|
||||
userEvent.hover(importButton);
|
||||
|
||||
await screen.findByRole('tooltip');
|
||||
const importTooltip = screen.getByRole('tooltip', {
|
||||
name: 'Import datasets',
|
||||
});
|
||||
|
||||
expect(importTooltip).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prevent unsafe URLs', () => {
|
||||
const columnCount = 8;
|
||||
const exploreUrlIndex = 1;
|
||||
const getTdIndex = (rowNumber: number): number =>
|
||||
rowNumber * columnCount + exploreUrlIndex;
|
||||
|
||||
const mockedProps = {};
|
||||
let wrapper: any;
|
||||
|
||||
it('Check prevent unsafe is on renders relative links', async () => {
|
||||
useSelectorMock.mockReturnValue(true);
|
||||
wrapper = await mountAndWait(mockedProps);
|
||||
const tdElements = wrapper.find(ListView).find('td');
|
||||
expect(tdElements.at(getTdIndex(0)).find('a').prop('href')).toBe(
|
||||
'/https://www.google.com?0',
|
||||
);
|
||||
expect(tdElements.at(getTdIndex(1)).find('a').prop('href')).toBe(
|
||||
'/https://www.google.com?1',
|
||||
);
|
||||
expect(tdElements.at(getTdIndex(2)).find('a').prop('href')).toBe(
|
||||
'/https://www.google.com?2',
|
||||
);
|
||||
});
|
||||
|
||||
it('Check prevent unsafe is off renders absolute links', async () => {
|
||||
useSelectorMock.mockReturnValue(false);
|
||||
wrapper = await mountAndWait(mockedProps);
|
||||
const tdElements = wrapper.find(ListView).find('td');
|
||||
expect(tdElements.at(getTdIndex(0)).find('a').prop('href')).toBe(
|
||||
'https://www.google.com?0',
|
||||
);
|
||||
expect(tdElements.at(getTdIndex(1)).find('a').prop('href')).toBe(
|
||||
'https://www.google.com?1',
|
||||
);
|
||||
expect(tdElements.at(getTdIndex(2)).find('a').prop('href')).toBe(
|
||||
'https://www.google.com?2',
|
||||
);
|
||||
});
|
||||
});
|
||||
165
superset-frontend/src/pages/GroupsList/GroupsList.test.tsx
Normal file
165
superset-frontend/src/pages/GroupsList/GroupsList.test.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 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 fetchMock from 'fetch-mock';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
act,
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import GroupsList from './index';
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
const mockUser = {
|
||||
userId: 1,
|
||||
firstName: 'Admin',
|
||||
lastName: 'User',
|
||||
roles: [{ id: 1, name: 'Admin' }],
|
||||
};
|
||||
|
||||
const rolesEndpoint = 'glob:*/security/roles/?*';
|
||||
const usersEndpoint = 'glob:*/security/users/?*';
|
||||
|
||||
const mockRoles = Array.from({ length: 3 }, (_, i) => ({
|
||||
id: i,
|
||||
name: `role ${i}`,
|
||||
}));
|
||||
|
||||
const mockUsers = Array.from({ length: 3 }, (_, i) => ({
|
||||
id: i,
|
||||
username: `user${i}`,
|
||||
}));
|
||||
|
||||
fetchMock.get(usersEndpoint, {
|
||||
result: mockUsers,
|
||||
count: 3,
|
||||
});
|
||||
|
||||
fetchMock.get(rolesEndpoint, {
|
||||
result: mockRoles,
|
||||
count: 3,
|
||||
});
|
||||
|
||||
jest.mock('src/dashboard/util/permissionUtils', () => ({
|
||||
...jest.requireActual('src/dashboard/util/permissionUtils'),
|
||||
isUserAdmin: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
describe('GroupsList', () => {
|
||||
const renderComponent = async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider>
|
||||
<GroupsList user={mockUser} />
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ useRedux: true, store },
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.resetHistory();
|
||||
});
|
||||
|
||||
it('renders the page', async () => {
|
||||
await renderComponent();
|
||||
expect(await screen.findByText('List Groups')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fetches roles on load', async () => {
|
||||
await renderComponent();
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls(rolesEndpoint).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders add group button and triggers modal', async () => {
|
||||
await renderComponent();
|
||||
const addButton = screen.getByTestId('add-group-button');
|
||||
fireEvent.click(addButton);
|
||||
expect(await screen.findByTestId('Add Group-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders actions column for admin', async () => {
|
||||
await renderComponent();
|
||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the filters correctly', async () => {
|
||||
await renderComponent();
|
||||
const filtersSelect = screen.getAllByTestId('filters-select')[0];
|
||||
|
||||
expect(within(filtersSelect).getByText(/name/i)).toBeInTheDocument();
|
||||
expect(within(filtersSelect).getByText(/label/i)).toBeInTheDocument();
|
||||
expect(within(filtersSelect).getByText(/description/i)).toBeInTheDocument();
|
||||
expect(within(filtersSelect).getByText(/roles/i)).toBeInTheDocument();
|
||||
expect(within(filtersSelect).getByText(/users/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct columns in the table', async () => {
|
||||
await renderComponent();
|
||||
const table = screen.getByRole('table');
|
||||
|
||||
expect(await within(table).findByText('Name')).toBeInTheDocument();
|
||||
expect(await within(table).findByText('Label')).toBeInTheDocument();
|
||||
expect(await within(table).findByText('Roles')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens add group modal on button click', async () => {
|
||||
await renderComponent();
|
||||
const addButton = screen.getByTestId('add-group-button');
|
||||
fireEvent.click(addButton);
|
||||
|
||||
expect(await screen.findByTestId('Add Group-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens edit modal on edit button click', async () => {
|
||||
fetchMock.get('glob:*/security/groups/?*', {
|
||||
result: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Editors',
|
||||
label: 'editors',
|
||||
description: 'Group for editors',
|
||||
roles: mockRoles,
|
||||
users: mockUsers,
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
});
|
||||
|
||||
await renderComponent();
|
||||
|
||||
const editButtons = await screen.findAllByTestId('group-list-edit-action');
|
||||
fireEvent.click(editButtons[0]);
|
||||
|
||||
expect(await screen.findByTestId('Edit Group-modal')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
459
superset-frontend/src/pages/GroupsList/index.tsx
Normal file
459
superset-frontend/src/pages/GroupsList/index.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* 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 { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { css, t, useTheme } from '@superset-ui/core';
|
||||
import { useListViewResource } from 'src/views/CRUD/hooks';
|
||||
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
|
||||
import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
|
||||
import ListView, {
|
||||
ListViewProps,
|
||||
Filters,
|
||||
FilterOperator,
|
||||
} from 'src/components/ListView';
|
||||
import DeleteModal from 'src/components/DeleteModal';
|
||||
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
||||
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
import { Icons } from 'src/components/Icons';
|
||||
import {
|
||||
GroupListAddModal,
|
||||
GroupListEditModal,
|
||||
} from 'src/features/groups/GroupListModal';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { deleteGroup, fetchUserOptions } from 'src/features/groups/utils';
|
||||
import { fetchPaginatedData } from 'src/utils/fetchOptions';
|
||||
import { Tooltip } from 'src/components/Tooltip';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
interface GroupsListProps {
|
||||
user: {
|
||||
userId: string | number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
roles: object;
|
||||
};
|
||||
}
|
||||
|
||||
export type Role = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type GroupObject = {
|
||||
id: number;
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
roles: Role[];
|
||||
users: User[];
|
||||
};
|
||||
|
||||
enum ModalType {
|
||||
ADD = 'add',
|
||||
EDIT = 'edit',
|
||||
}
|
||||
|
||||
function GroupsList({ user }: GroupsListProps) {
|
||||
const theme = useTheme();
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
const {
|
||||
state: {
|
||||
loading,
|
||||
resourceCount: groupsCount,
|
||||
resourceCollection: groups,
|
||||
bulkSelectEnabled,
|
||||
},
|
||||
fetchData,
|
||||
refreshData,
|
||||
toggleBulkSelect,
|
||||
} = useListViewResource<GroupObject>(
|
||||
'security/groups',
|
||||
t('Group'),
|
||||
addDangerToast,
|
||||
);
|
||||
const [modalState, setModalState] = useState({
|
||||
edit: false,
|
||||
add: false,
|
||||
});
|
||||
const openModal = (type: ModalType) =>
|
||||
setModalState(prev => ({ ...prev, [type]: true }));
|
||||
const closeModal = (type: ModalType) =>
|
||||
setModalState(prev => ({ ...prev, [type]: false }));
|
||||
|
||||
const [currentGroup, setCurrentGroup] = useState<GroupObject | null>(null);
|
||||
const [groupCurrentlyDeleting, setGroupCurrentlyDeleting] =
|
||||
useState<GroupObject | null>(null);
|
||||
const [loadingState, setLoadingState] = useState({
|
||||
roles: true,
|
||||
});
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
|
||||
const isAdmin = useMemo(() => isUserAdmin(user), [user]);
|
||||
|
||||
const fetchRoles = useCallback(() => {
|
||||
fetchPaginatedData({
|
||||
endpoint: '/api/v1/security/roles/',
|
||||
setData: setRoles,
|
||||
setLoadingState,
|
||||
loadingKey: 'roles',
|
||||
addDangerToast,
|
||||
errorMessage: t('Error while fetching roles'),
|
||||
});
|
||||
}, [addDangerToast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
}, [fetchRoles]);
|
||||
|
||||
const handleGroupDelete = async ({ id, name }: GroupObject) => {
|
||||
try {
|
||||
await deleteGroup(id);
|
||||
refreshData();
|
||||
setGroupCurrentlyDeleting(null);
|
||||
addSuccessToast(t('Deleted group: %s', name));
|
||||
} catch (error) {
|
||||
addDangerToast(t('There was an issue deleting %s', name));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkGroupsDelete = (groupsToDelete: GroupObject[]) => {
|
||||
const deletedGroupsNames: string[] = [];
|
||||
|
||||
Promise.all(
|
||||
groupsToDelete.map(group =>
|
||||
deleteGroup(group.id)
|
||||
.then(() => {
|
||||
deletedGroupsNames.push(group.name);
|
||||
})
|
||||
.catch(err => {
|
||||
addDangerToast(t('Error deleting %s', group.name));
|
||||
}),
|
||||
),
|
||||
)
|
||||
.then(() => {
|
||||
if (deletedGroupsNames.length > 0) {
|
||||
addSuccessToast(
|
||||
t('Deleted groups: %s', deletedGroupsNames.join(', ')),
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
refreshData();
|
||||
});
|
||||
};
|
||||
|
||||
const initialSort = [{ id: 'name', desc: true }];
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessor: 'name',
|
||||
id: 'name',
|
||||
Header: t('Name'),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { name },
|
||||
},
|
||||
}: any) => <span>{name}</span>,
|
||||
},
|
||||
{
|
||||
accessor: 'label',
|
||||
id: 'label',
|
||||
Header: t('Label'),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { label },
|
||||
},
|
||||
}: any) => <span>{label}</span>,
|
||||
},
|
||||
{
|
||||
accessor: 'description',
|
||||
id: 'description',
|
||||
Header: t('Description'),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { description },
|
||||
},
|
||||
}: any) => <span>{description}</span>,
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
accessor: 'roles',
|
||||
id: 'roles',
|
||||
Header: t('Roles'),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { roles },
|
||||
},
|
||||
}: any) => (
|
||||
<Tooltip
|
||||
title={
|
||||
roles?.map((role: Role) => role.name).join(', ') || t('No roles')
|
||||
}
|
||||
>
|
||||
<span>{roles?.map((role: Role) => role.name).join(', ')}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
disableSortBy: true,
|
||||
},
|
||||
{
|
||||
accessor: 'users',
|
||||
id: 'users',
|
||||
Header: t('Users'),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { users },
|
||||
},
|
||||
}: any) => (
|
||||
<span>{users?.map((user: User) => user.username).join(', ')}</span>
|
||||
),
|
||||
disableSortBy: true,
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
Cell: ({ row: { original } }: any) => {
|
||||
const handleEdit = () => {
|
||||
setCurrentGroup(original);
|
||||
openModal(ModalType.EDIT);
|
||||
};
|
||||
const handleDelete = () => setGroupCurrentlyDeleting(original);
|
||||
const actions = isAdmin
|
||||
? [
|
||||
{
|
||||
label: 'group-list-edit-action',
|
||||
tooltip: t('Edit group'),
|
||||
placement: 'bottom',
|
||||
icon: 'EditOutlined',
|
||||
onClick: handleEdit,
|
||||
},
|
||||
{
|
||||
label: 'group-list-delete-action',
|
||||
tooltip: t('Delete group'),
|
||||
placement: 'bottom',
|
||||
icon: 'DeleteOutlined',
|
||||
onClick: handleDelete,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return <ActionsBar actions={actions as ActionProps[]} />;
|
||||
},
|
||||
Header: t('Actions'),
|
||||
id: 'actions',
|
||||
disableSortBy: true,
|
||||
hidden: !isAdmin,
|
||||
size: 'xl',
|
||||
},
|
||||
],
|
||||
[isAdmin],
|
||||
);
|
||||
|
||||
const subMenuButtons: SubMenuProps['buttons'] = [];
|
||||
|
||||
if (isAdmin) {
|
||||
subMenuButtons.push(
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<Icons.PlusOutlined
|
||||
iconColor={theme.colors.primary.light5}
|
||||
iconSize="m"
|
||||
css={css`
|
||||
margin: auto ${theme.gridUnit * 2}px auto 0;
|
||||
vertical-align: text-top;
|
||||
`}
|
||||
/>
|
||||
{t('Group')}
|
||||
</>
|
||||
),
|
||||
buttonStyle: 'primary',
|
||||
onClick: () => {
|
||||
openModal(ModalType.ADD);
|
||||
},
|
||||
loading: loadingState.roles,
|
||||
'data-test': 'add-group-button',
|
||||
},
|
||||
{
|
||||
name: t('Bulk select'),
|
||||
onClick: toggleBulkSelect,
|
||||
buttonStyle: 'secondary',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const filters: Filters = useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: t('Name'),
|
||||
key: 'name',
|
||||
id: 'name',
|
||||
input: 'search',
|
||||
operator: FilterOperator.Contains,
|
||||
},
|
||||
{
|
||||
Header: t('Label'),
|
||||
key: 'label',
|
||||
id: 'label',
|
||||
input: 'search',
|
||||
operator: FilterOperator.Contains,
|
||||
},
|
||||
{
|
||||
Header: t('Description'),
|
||||
key: 'description',
|
||||
id: 'description',
|
||||
input: 'search',
|
||||
operator: FilterOperator.Contains,
|
||||
},
|
||||
{
|
||||
Header: t('Roles'),
|
||||
key: 'roles',
|
||||
id: 'roles',
|
||||
input: 'select',
|
||||
operator: FilterOperator.RelationManyMany,
|
||||
unfilteredLabel: t('All'),
|
||||
selects: roles?.map(role => ({
|
||||
label: role.name,
|
||||
value: role.id,
|
||||
})),
|
||||
loading: loadingState.roles,
|
||||
},
|
||||
{
|
||||
Header: t('Users'),
|
||||
key: 'users',
|
||||
id: 'users',
|
||||
input: 'select',
|
||||
operator: FilterOperator.RelationManyMany,
|
||||
unfilteredLabel: t('All'),
|
||||
fetchSelects: async (filterValue, page, pageSize) =>
|
||||
fetchUserOptions(filterValue, page, pageSize, addDangerToast),
|
||||
},
|
||||
],
|
||||
[loadingState.roles, roles],
|
||||
);
|
||||
|
||||
const emptyState = {
|
||||
title: t('No groups yet'),
|
||||
image: 'filter-results.svg',
|
||||
...(isAdmin && {
|
||||
buttonAction: () => {
|
||||
openModal(ModalType.ADD);
|
||||
},
|
||||
buttonText: (
|
||||
<>
|
||||
<Icons.PlusOutlined
|
||||
iconColor={theme.colors.primary.light5}
|
||||
iconSize="m"
|
||||
css={css`
|
||||
margin: auto ${theme.gridUnit * 2}px auto 0;
|
||||
vertical-align: text-top;
|
||||
`}
|
||||
/>
|
||||
{t('Group')}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubMenu name={t('List Groups')} buttons={subMenuButtons} />
|
||||
<GroupListAddModal
|
||||
onHide={() => closeModal(ModalType.ADD)}
|
||||
show={modalState.add}
|
||||
onSave={() => {
|
||||
refreshData();
|
||||
closeModal(ModalType.ADD);
|
||||
}}
|
||||
roles={roles}
|
||||
/>
|
||||
{modalState.edit && currentGroup && (
|
||||
<GroupListEditModal
|
||||
group={currentGroup}
|
||||
show={modalState.edit}
|
||||
onHide={() => closeModal(ModalType.EDIT)}
|
||||
onSave={() => {
|
||||
refreshData();
|
||||
closeModal(ModalType.EDIT);
|
||||
}}
|
||||
roles={roles}
|
||||
/>
|
||||
)}
|
||||
|
||||
{groupCurrentlyDeleting && (
|
||||
<DeleteModal
|
||||
description={t('This action will permanently delete the group.')}
|
||||
onConfirm={() => {
|
||||
if (groupCurrentlyDeleting) {
|
||||
handleGroupDelete(groupCurrentlyDeleting);
|
||||
}
|
||||
}}
|
||||
onHide={() => setGroupCurrentlyDeleting(null)}
|
||||
open
|
||||
title={t('Delete Group?')}
|
||||
/>
|
||||
)}
|
||||
<ConfirmStatusChange
|
||||
title={t('Please confirm')}
|
||||
description={t('Are you sure you want to delete the selected groups?')}
|
||||
onConfirm={handleBulkGroupsDelete}
|
||||
>
|
||||
{confirmDelete => {
|
||||
const bulkActions: ListViewProps['bulkActions'] = isAdmin
|
||||
? [
|
||||
{
|
||||
key: 'delete',
|
||||
name: t('Delete'),
|
||||
onSelect: confirmDelete,
|
||||
type: 'danger',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<ListView<GroupObject>
|
||||
className="group-list-view"
|
||||
columns={columns}
|
||||
count={groupsCount}
|
||||
data={groups}
|
||||
fetchData={fetchData}
|
||||
filters={filters}
|
||||
initialSort={initialSort}
|
||||
loading={loading}
|
||||
pageSize={PAGE_SIZE}
|
||||
bulkActions={bulkActions}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
disableBulkSelect={toggleBulkSelect}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
emptyState={emptyState}
|
||||
refreshData={refreshData}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ConfirmStatusChange>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupsList;
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { MouseEvent } from 'react';
|
||||
import thunk from 'redux-thunk';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import { Provider } from 'react-redux';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { act } from 'spec/helpers/testing-library';
|
||||
|
||||
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
|
||||
import { styledMount as mount } from 'spec/helpers/theming';
|
||||
|
||||
import QueryList from 'src/pages/QueryHistoryList';
|
||||
import QueryPreviewModal from 'src/features/queries/QueryPreviewModal';
|
||||
import { QueryObject } from 'src/views/CRUD/types';
|
||||
import ListView from 'src/components/ListView';
|
||||
import Filters from 'src/components/ListView/Filters';
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light';
|
||||
import SubMenu from 'src/features/home/SubMenu';
|
||||
import { QueryState } from '@superset-ui/core';
|
||||
|
||||
// store needed for withToasts
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
const queriesEndpoint = 'glob:*/api/v1/query/?*';
|
||||
|
||||
const mockQueries: QueryObject[] = [...new Array(3)].map((_, i) => ({
|
||||
changed_on: new Date().toISOString(),
|
||||
id: i,
|
||||
slice_name: `cool chart ${i}`,
|
||||
database: {
|
||||
database_name: 'main db',
|
||||
},
|
||||
schema: 'public',
|
||||
sql: `SELECT ${i} FROM table`,
|
||||
executed_sql: `SELECT ${i} FROM table`,
|
||||
sql_tables: [
|
||||
{ schema: 'foo', table: 'table' },
|
||||
{ schema: 'bar', table: 'table_2' },
|
||||
],
|
||||
status: QueryState.Success,
|
||||
tab_name: 'Main Tab',
|
||||
user: {
|
||||
first_name: 'cool',
|
||||
last_name: 'dude',
|
||||
id: 2,
|
||||
username: 'cooldude',
|
||||
},
|
||||
start_time: new Date().valueOf(),
|
||||
end_time: new Date().valueOf(),
|
||||
rows: 200,
|
||||
tmp_table_name: '',
|
||||
tracking_url: '',
|
||||
}));
|
||||
|
||||
fetchMock.get(queriesEndpoint, {
|
||||
result: mockQueries,
|
||||
chart_count: 3,
|
||||
});
|
||||
|
||||
fetchMock.get('glob:*/api/v1/query/related/user*', {
|
||||
result: [],
|
||||
count: 0,
|
||||
});
|
||||
fetchMock.get('glob:*/api/v1/query/related/database*', {
|
||||
result: [],
|
||||
count: 0,
|
||||
});
|
||||
fetchMock.get('glob:*/api/v1/query/disting/status*', {
|
||||
result: [],
|
||||
count: 0,
|
||||
});
|
||||
|
||||
describe('QueryList', () => {
|
||||
const mockedProps = {};
|
||||
const wrapper = mount(
|
||||
<Provider store={store}>
|
||||
<QueryList {...mockedProps} />
|
||||
</Provider>,
|
||||
{
|
||||
context: { store },
|
||||
},
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
await waitForComponentToPaint(wrapper);
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
expect(wrapper.find(QueryList)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a ListView', () => {
|
||||
expect(wrapper.find(ListView)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fetches data', () => {
|
||||
wrapper.update();
|
||||
const callsD = fetchMock.calls(/query\/\?q/);
|
||||
expect(callsD).toHaveLength(1);
|
||||
expect(callsD[0][0]).toMatchInlineSnapshot(
|
||||
`"http://localhost/api/v1/query/?q=(order_column:start_time,order_direction:desc,page:0,page_size:25)"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a SyntaxHighlight', () => {
|
||||
expect(wrapper.find(SyntaxHighlighter)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens a query preview', () => {
|
||||
act(() => {
|
||||
const props = wrapper
|
||||
.find('[data-test="open-sql-preview-0"]')
|
||||
.first()
|
||||
.props();
|
||||
if (props.onClick) props.onClick({} as MouseEvent);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find(QueryPreviewModal)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('searches', async () => {
|
||||
const filtersWrapper = wrapper.find(Filters);
|
||||
act(() => {
|
||||
const props = filtersWrapper.find('[name="sql"]').first().props();
|
||||
// @ts-ignore
|
||||
if (props.onSubmit) props.onSubmit('fooo');
|
||||
});
|
||||
await waitForComponentToPaint(wrapper);
|
||||
expect((fetchMock.lastCall() ?? [])[0]).toMatchInlineSnapshot(
|
||||
`"http://localhost/api/v1/query/?q=(filters:!((col:sql,opr:ct,value:fooo)),order_column:start_time,order_direction:desc,page:0,page_size:25)"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a SubMenu', () => {
|
||||
expect(wrapper.find(SubMenu)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a SubMenu with Saved queries and Query History links', () => {
|
||||
expect(wrapper.find(SubMenu).props().tabs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: 'Saved queries' }),
|
||||
expect.objectContaining({ label: 'Query history' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a SubMenu without Databases and Datasets links', () => {
|
||||
expect(wrapper.find(SubMenu).props().tabs).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: 'Databases' }),
|
||||
expect.objectContaining({ label: 'Datasets' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -137,13 +137,11 @@ describe('RolesList', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches permissions and users on load', async () => {
|
||||
it('fetches permissions on load', async () => {
|
||||
await renderAndWait();
|
||||
await waitFor(() => {
|
||||
const permissionCalls = fetchMock.calls(permissionsEndpoint);
|
||||
const userCalls = fetchMock.calls(usersEndpoint);
|
||||
expect(permissionCalls.length).toBeGreaterThan(0);
|
||||
expect(userCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,7 +149,7 @@ describe('RolesList', () => {
|
||||
await renderAndWait();
|
||||
|
||||
const typeFilter = screen.queryAllByTestId('filters-select');
|
||||
expect(typeFilter).toHaveLength(3);
|
||||
expect(typeFilter).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('renders correct list columns', async () => {
|
||||
|
||||
@@ -33,13 +33,12 @@ import ListView, {
|
||||
} from 'src/components/ListView';
|
||||
import DeleteModal from 'src/components/DeleteModal';
|
||||
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
||||
import {
|
||||
FormattedPermission,
|
||||
PermissionResource,
|
||||
UserObject,
|
||||
} from 'src/features/roles/types';
|
||||
import { FormattedPermission, UserObject } from 'src/features/roles/types';
|
||||
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
import { Icons } from 'src/components/Icons';
|
||||
import { fetchPaginatedData } from 'src/utils/fetchOptions';
|
||||
import { fetchUserOptions } from 'src/features/groups/utils';
|
||||
import { GroupObject } from '../GroupsList';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -60,6 +59,7 @@ export type RoleObject = {
|
||||
permission_ids: number[];
|
||||
users?: Array<UserObject>;
|
||||
user_ids: number[];
|
||||
group_ids: number[];
|
||||
};
|
||||
|
||||
enum ModalType {
|
||||
@@ -100,108 +100,48 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
|
||||
const [roleCurrentlyDeleting, setRoleCurrentlyDeleting] =
|
||||
useState<RoleObject | null>(null);
|
||||
const [permissions, setPermissions] = useState<FormattedPermission[]>([]);
|
||||
const [users, setUsers] = useState<UserObject[]>([]);
|
||||
const [groups, setGroups] = useState<GroupObject[]>([]);
|
||||
const [loadingState, setLoadingState] = useState({
|
||||
permissions: true,
|
||||
users: true,
|
||||
groups: true,
|
||||
});
|
||||
|
||||
const isAdmin = useMemo(() => isUserAdmin(user), [user]);
|
||||
|
||||
const fetchPermissions = useCallback(async () => {
|
||||
try {
|
||||
const pageSize = 100;
|
||||
const fetchPermissions = useCallback(() => {
|
||||
fetchPaginatedData({
|
||||
endpoint: '/api/v1/security/permissions-resources/',
|
||||
setData: setPermissions,
|
||||
setLoadingState,
|
||||
loadingKey: 'permissions',
|
||||
addDangerToast,
|
||||
errorMessage: 'Error while fetching permissions',
|
||||
mapResult: ({ permission, view_menu, id }) => ({
|
||||
label: `${permission.name.replace(/_/g, ' ')} ${view_menu.name.replace(/_/g, ' ')}`,
|
||||
value: `${permission.name}__${view_menu.name}`,
|
||||
id,
|
||||
}),
|
||||
});
|
||||
}, [addDangerToast]);
|
||||
|
||||
const fetchPage = async (pageIndex: number) => {
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: `api/v1/security/permissions-resources/?q=(page_size:${pageSize},page:${pageIndex})`,
|
||||
});
|
||||
|
||||
return {
|
||||
count: response.json.count,
|
||||
results: response.json.result.map(
|
||||
({ permission, view_menu, id }: PermissionResource) => ({
|
||||
label: `${permission.name.replace(/_/g, ' ')} ${view_menu.name.replace(/_/g, ' ')}`,
|
||||
value: `${permission.name}__${view_menu.name}`,
|
||||
id,
|
||||
}),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const initialResponse = await fetchPage(0);
|
||||
const totalPermissions = initialResponse.count;
|
||||
const firstPageResults = initialResponse.results;
|
||||
|
||||
if (firstPageResults.length >= totalPermissions) {
|
||||
setPermissions(firstPageResults);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(totalPermissions / pageSize);
|
||||
|
||||
const permissionRequests = Array.from(
|
||||
{ length: totalPages - 1 },
|
||||
(_, i) => fetchPage(i + 1),
|
||||
);
|
||||
const remainingResults = await Promise.all(permissionRequests);
|
||||
|
||||
setPermissions([
|
||||
...firstPageResults,
|
||||
...remainingResults.flatMap(res => res.results),
|
||||
]);
|
||||
} catch (err) {
|
||||
addDangerToast(t('Error while fetching permissions'));
|
||||
} finally {
|
||||
setLoadingState(prev => ({ ...prev, permissions: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const pageSize = 100;
|
||||
|
||||
const fetchPage = async (pageIndex: number) => {
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: `api/v1/security/users/?q=(page_size:${pageSize},page:${pageIndex})`,
|
||||
});
|
||||
return response.json;
|
||||
};
|
||||
|
||||
const initialResponse = await fetchPage(0);
|
||||
const totalUsers = initialResponse.count;
|
||||
const firstPageResults = initialResponse.result;
|
||||
|
||||
if (pageSize >= totalUsers) {
|
||||
setUsers(firstPageResults);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(totalUsers / pageSize);
|
||||
|
||||
const userRequests = Array.from({ length: totalPages - 1 }, (_, i) =>
|
||||
fetchPage(i + 1),
|
||||
);
|
||||
const remainingResults = await Promise.all(userRequests);
|
||||
|
||||
setUsers([
|
||||
...firstPageResults,
|
||||
...remainingResults.flatMap(res => res.result),
|
||||
]);
|
||||
} catch (err) {
|
||||
addDangerToast(t('Error while fetching users'));
|
||||
} finally {
|
||||
setLoadingState(prev => ({ ...prev, users: false }));
|
||||
}
|
||||
}, []);
|
||||
const fetchGroups = useCallback(() => {
|
||||
fetchPaginatedData({
|
||||
endpoint: '/api/v1/security/groups/',
|
||||
setData: setGroups,
|
||||
setLoadingState,
|
||||
loadingKey: 'groups',
|
||||
addDangerToast,
|
||||
errorMessage: t('Error while fetching groups'),
|
||||
});
|
||||
}, [addDangerToast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPermissions();
|
||||
}, [fetchPermissions]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
fetchGroups();
|
||||
}, [fetchGroups]);
|
||||
|
||||
const handleRoleDelete = async ({ id, name }: RoleObject) => {
|
||||
try {
|
||||
@@ -246,6 +186,7 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
|
||||
() => [
|
||||
{
|
||||
accessor: 'name',
|
||||
id: 'name',
|
||||
Header: t('Name'),
|
||||
Cell: ({
|
||||
row: {
|
||||
@@ -255,12 +196,21 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
|
||||
},
|
||||
{
|
||||
accessor: 'user_ids',
|
||||
id: 'user_ids',
|
||||
Header: t('Users'),
|
||||
hidden: true,
|
||||
Cell: ({ row: { original } }: any) => original.user_ids.join(', '),
|
||||
},
|
||||
{
|
||||
accessor: 'group_ids',
|
||||
id: 'group_ids',
|
||||
Header: t('Groups'),
|
||||
hidden: true,
|
||||
Cell: ({ row: { original } }: any) => original.groups_ids.join(', '),
|
||||
},
|
||||
{
|
||||
accessor: 'permission_ids',
|
||||
id: 'permission_ids',
|
||||
Header: t('Permissions'),
|
||||
hidden: true,
|
||||
Cell: ({ row: { original } }: any) =>
|
||||
@@ -365,11 +315,8 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
|
||||
input: 'select',
|
||||
operator: FilterOperator.RelationOneMany,
|
||||
unfilteredLabel: t('All'),
|
||||
selects: users?.map(user => ({
|
||||
label: user.username,
|
||||
value: user.id,
|
||||
})),
|
||||
loading: loadingState.users,
|
||||
fetchSelects: async (filterValue, page, pageSize) =>
|
||||
fetchUserOptions(filterValue, page, pageSize, addDangerToast),
|
||||
},
|
||||
{
|
||||
Header: t('Permissions'),
|
||||
@@ -384,8 +331,21 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
|
||||
})),
|
||||
loading: loadingState.permissions,
|
||||
},
|
||||
{
|
||||
Header: t('Groups'),
|
||||
key: 'group_ids',
|
||||
id: 'group_ids',
|
||||
input: 'select',
|
||||
operator: FilterOperator.RelationOneMany,
|
||||
unfilteredLabel: t('All'),
|
||||
selects: groups?.map(group => ({
|
||||
label: group.name,
|
||||
value: group.id,
|
||||
})),
|
||||
loading: loadingState.groups,
|
||||
},
|
||||
],
|
||||
[permissions, users, loadingState.users, loadingState.permissions],
|
||||
[permissions, groups, loadingState.groups, loadingState.permissions],
|
||||
);
|
||||
|
||||
const emptyState = {
|
||||
@@ -431,10 +391,9 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
|
||||
onSave={() => {
|
||||
refreshData();
|
||||
closeModal(ModalType.EDIT);
|
||||
fetchUsers();
|
||||
}}
|
||||
permissions={permissions}
|
||||
users={users}
|
||||
groups={groups}
|
||||
/>
|
||||
)}
|
||||
{modalState.duplicate && currentRole && (
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useLocation } from 'react-router-dom';
|
||||
|
||||
export type LocationState = {
|
||||
requestedQuery?: Record<string, any>;
|
||||
isDataset?: boolean;
|
||||
};
|
||||
|
||||
export const locationContext = createContext<LocationState>({});
|
||||
@@ -32,7 +33,24 @@ const EMPTY_STATE: LocationState = {};
|
||||
|
||||
export const LocationProvider: FC = ({ children }: { children: ReactNode }) => {
|
||||
const location = useLocation<LocationState>();
|
||||
return <Provider value={location.state || EMPTY_STATE}>{children}</Provider>;
|
||||
};
|
||||
if (location.state) {
|
||||
return <Provider value={location.state}>{children}</Provider>;
|
||||
}
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
if (queryParams.size > 0) {
|
||||
const dbid = queryParams.get('dbid');
|
||||
const sql = queryParams.get('sql');
|
||||
const name = queryParams.get('name');
|
||||
const schema = queryParams.get('schema');
|
||||
const autorun = queryParams.get('autorun') === 'true';
|
||||
|
||||
const queryParamsState = {
|
||||
requestedQuery: { dbid, sql, name, schema, autorun },
|
||||
isDataset: true,
|
||||
} as LocationState;
|
||||
return <Provider value={queryParamsState}>{children}</Provider>;
|
||||
}
|
||||
|
||||
return <Provider value={EMPTY_STATE}>{children}</Provider>;
|
||||
};
|
||||
export const useLocationState = () => useContext(locationContext);
|
||||
|
||||
126
superset-frontend/src/pages/UserInfo/UserInfo.test.tsx
Normal file
126
superset-frontend/src/pages/UserInfo/UserInfo.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 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 fetchMock from 'fetch-mock';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
act,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import UserInfo from 'src/pages/UserInfo';
|
||||
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
const meEndpoint = 'glob:*/api/v1/me/';
|
||||
|
||||
const mockUser: UserWithPermissionsAndRoles = {
|
||||
userId: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
username: 'johndoe',
|
||||
email: 'john@example.com',
|
||||
isActive: true,
|
||||
loginCount: 12,
|
||||
roles: {
|
||||
Admin: [
|
||||
['can_read', 'Dashboard'],
|
||||
['can_write', 'Chart'],
|
||||
],
|
||||
},
|
||||
createdOn: new Date().toISOString(),
|
||||
isAnonymous: false,
|
||||
permissions: {
|
||||
database_access: ['examples', 'birth_names'],
|
||||
datasource_access: ['examples.babynames', 'examples.world_health'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('UserInfo', () => {
|
||||
const renderPage = async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider>
|
||||
<UserInfo user={mockUser} />
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ useRedux: true, store },
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.restore();
|
||||
fetchMock.get(meEndpoint, {
|
||||
result: {
|
||||
...mockUser,
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('renders the user info page', async () => {
|
||||
await renderPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText('Your user information'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('johndoe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Yes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||
expect(screen.getByText('12')).toBeInTheDocument();
|
||||
expect(await screen.findByText('John')).toBeInTheDocument();
|
||||
expect(screen.getByText('Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('john@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls the /me endpoint on mount', async () => {
|
||||
await renderPage();
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.called(meEndpoint)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the reset password modal on button click', async () => {
|
||||
await renderPage();
|
||||
const button = await screen.findByTestId('reset-password-button');
|
||||
fireEvent.click(button);
|
||||
expect(await screen.findByText(/Reset password/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the edit user modal on button click', async () => {
|
||||
await renderPage();
|
||||
const button = await screen.findByTestId('edit-user-button');
|
||||
fireEvent.click(button);
|
||||
expect(await screen.getAllByText(/Edit user/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user