Compare commits

..

62 Commits

Author SHA1 Message Date
Evan Rusackas
fca4900cd9 fix: maintain pagination state on long echarts legends. 2025-06-09 16:45:22 -06:00
dependabot[bot]
ca74ae75a6 chore(deps-dev): bump webpack from 5.99.8 to 5.99.9 in /docs (#33643)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-09 16:19:49 -06:00
dependabot[bot]
ae6c072661 chore(deps-dev): bump @docusaurus/tsconfig from 3.7.0 to 3.8.0 in /docs (#33645)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-09 16:19:25 -06:00
dependabot[bot]
5f2f12d347 chore(deps-dev): bump @typescript-eslint/parser from 8.29.0 to 8.33.0 in /superset-websocket (#33650)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-09 16:19:11 -06:00
Le Xich Long
fc7ba060c1 feat(clickhouse): allow dynamic schema (#32610) 2025-06-09 16:18:05 -06:00
JUST.in DO IT
3a3984006c chore(explore): Add format sql and view in SQL Lab option in View Query (#33341) 2025-06-09 15:11:54 -07:00
nmdo
d11b6d557e feat(MixedTimeSeries): Add onlyTotal and Sort Series to Mixed TimeSeries (#33634) 2025-06-09 15:59:54 -06:00
Beto Dealmeida
2f007bf7a5 fix: typo in SQL dialect map (#33727) 2025-06-09 17:03:31 -04:00
Lourdu Radjou🎶
6513445000 docs: fix typo and improve alt text in README (#33721)
Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>
2025-06-09 12:15:14 -06:00
Vladislav Korenkov
3ef92e5610 fix(Alerts & reports): invalid "Last updated" time formatting (#33719) 2025-06-09 20:52:47 +03:00
Pat Buxton
57bb425fb0 fix(dashboard): show dashboard thumbnail images when retrieved (#33726) 2025-06-09 14:33:54 -03:00
jqqin
2fba789e8d fix(dataset): prevent metric duplication error when editing SQL and adding metric (#33523)
Co-authored-by: QinQin <qinqin@geotab.com>
2025-06-09 09:42:55 -07:00
Luiz Otavio
08655a7559 fix: Migrate charts with empty query_context (#33710) 2025-06-09 08:32:12 -03:00
Maxime Beauchemin
3256008a59 chore: delete remaining Enzyme tests (#33715) 2025-06-06 16:37:03 -07:00
Maxime Beauchemin
da8efd36d7 docs: clarify how requirements/ should be modified (#33714) 2025-06-06 16:30:27 -07:00
Denodo Research Labs
5541dad32b fix(compose): environment entries in compose*.yml override values in docker/.env (#33700) 2025-06-06 14:24:26 -07:00
Beto Dealmeida
b3f436a030 chore: remove unused parameter (#33704) 2025-06-05 16:32:04 -04:00
Beto Dealmeida
b00660acf1 chore: update sqlglot dialect map (#33701) 2025-06-05 13:14:44 -04:00
Daniel Vaz Gaspar
a6af4f4d7a chore: simplify query cleanup using dict.pop instead of suppressing exception (#33661) 2025-06-05 09:24:28 +01:00
Kasper Mol
cc3460832f fix(template_processing): get_filters now works for IS_NULL and IS_NOT_NULL operators (#33296) 2025-06-05 00:46:33 -03:00
Beto Dealmeida
edc60914f6 chore: 100% test coverage for SQL parsing (#33568) 2025-06-04 22:18:09 -04:00
Vitor Avila
c9518485ba fix: Do not convert dataset changed_on to UTC (#33693) 2025-06-04 22:24:18 -03:00
Beto Dealmeida
a26e1d822a chore: remove sqlparse (#33564) 2025-06-04 19:31:41 -04:00
Rafael Benitez
a7aa8f7cef feat(Dataset): editor improvements - run in sqllab (#33443) 2025-06-04 21:47:12 +03:00
arafoperata
ff34e3c81e fix: optimize catalog permission sync when importing dashboards (#33679) 2025-06-04 13:21:17 -04:00
Enzo Martellucci
20519158d2 feat(UserInfo): Migrate User Info FAB to React (#33620) 2025-06-03 19:24:22 +02:00
Vitor Avila
cacf1e06d6 fix: Update dataset's last modified date from column/metric update (#33626) 2025-06-03 12:20:38 -03:00
Enzo Martellucci
fa0c5891bf feat(List Groups): Migrate List Groups FAB to React (#33301) 2025-06-03 16:18:15 +02:00
Anmol Mishra
fc13a0fde5 docs: add HPE to users list (#33665) 2025-06-02 15:31:06 -04:00
Adalbert Makarovych
ade85daee2 feat(database): add SingleStore engine specification (#32887)
Co-authored-by: Adalbert Makarovych <amakarovych0ua@singlestore.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2025-06-02 11:50:12 -06:00
Pat Buxton
2d26af25c1 feat: Python 3.12 support (#33434) 2025-06-02 10:00:37 -07:00
sha174n
b033406387 docs: CVE-2025-48912 added to 4.1.2 (#33662) 2025-06-02 10:03:29 +01:00
ethan-l-geotab
c09f8f6f76 fix(sqllab): save datasets with template parameters (#33195) 2025-05-30 19:57:33 -03:00
Beto Dealmeida
401ce56fa1 feat: use sqlglot to validate adhoc subquery (#33560) 2025-05-30 18:09:19 -04:00
Beto Dealmeida
cf315388f2 feat(sqllab): use sqlglot instead of sqlparse (#33542) 2025-05-30 17:08:19 -04:00
Beto Dealmeida
f219dc1794 chore: make DB syntax errors 400 (#33619) 2025-05-30 13:04:04 -04:00
Vitor Avila
ed20d2a917 fix(Security): Apply permissions to the AllEntities list/get_objects API endpoint (#33577) 2025-05-30 13:39:18 -03:00
dependabot[bot]
235c9d2ebf chore(deps-dev): bump fastify from 4.29.0 to 4.29.1 in /superset-frontend (#33622)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-29 16:12:20 -06:00
Vitor Avila
fdea4e21b0 feat: current_user_rls_rules Jinja macro (#33614) 2025-05-29 11:58:40 -03:00
Fardin Mustaque
e20a08cb14 fix: add query identifier to legend items in mixed time series charts (#33519) 2025-05-29 14:42:48 +02:00
Levis Mbote
429935a277 fix(big number with trendline): add None option to the aggregation method dropdown (#33407) 2025-05-29 14:33:04 +02:00
Daniel Vaz Gaspar
a4bb11c755 chore: bump FAB to 4.7.0 (#33607) 2025-05-29 08:34:00 +01:00
gpchandran
f0b6e87091 chore: update Dockerfile - Upgrade to 3.11.12 (#33612) 2025-05-29 00:31:07 -07:00
Beto Dealmeida
ea5a609d0b feat: implement CVAS/CTAS in sqlglot (#33525) 2025-05-28 09:45:59 -04:00
Beto Dealmeida
0abe6eed89 feat: implement RLS in sqlglot (#33524) 2025-05-28 09:10:45 -04:00
Beto Dealmeida
e205846845 feat: implement CTEs logic in sqlglot (#33518) 2025-05-28 08:38:00 -04:00
Enzo Martellucci
deef923825 feat(Action Logs): Migrate Action Log FAB to React (#33298) 2025-05-28 14:08:00 +02:00
Beto Dealmeida
0fa3feb088 chore: remove parse_sql (#33474) 2025-05-27 18:03:55 -04:00
Beto Dealmeida
1393f7d3d2 chore: sql/parse cleanup (#33515) 2025-05-27 16:42:04 -04:00
Michael S. Molina
b7ba50033a fix: Makes time compare migration more resilient (#33592) 2025-05-27 17:02:10 -03:00
Michael S. Molina
ce9759785a fix: Missing processor context when rendering Jinja (#33596) 2025-05-27 16:54:54 -03:00
Beto Dealmeida
8de58b9848 feat: use sqlglot to set limit (#33473) 2025-05-27 15:20:02 -04:00
Sam Firke
cc8ab2c556 chore(alerts & reports): increase Playwright timeout from 30 -> 60 seconds (#33567) 2025-05-27 13:22:23 -04:00
Urban Pettersson
1409b1a25b fix: correct typos (#33586)
Co-authored-by: Urban Pettersson <urban.pettersson@alteryx.com>
2025-05-27 08:24:17 -07:00
amaannawab923
bdfb698aa4 fix(Radar): Radar chart normalisation (#33559)
Co-authored-by: Amaan Nawab <nelsondrew07@gmail.com>
2025-05-26 22:01:17 +03:00
Luiz Otavio
57183da315 fix: Adjust viz migrations to also migrate the queries object (#33285)
Co-authored-by: Michael S. Molina <michael.s.molina@gmail.com>
Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>
2025-05-26 15:00:07 -03:00
Sam Firke
c928f23e1b docs(docker build): add more packages needed for production features (#33566) 2025-05-24 19:30:33 -04:00
dependabot[bot]
0c89914a6d chore(deps-dev): bump eslint-config-prettier from 9.1.0 to 10.1.5 in /superset-websocket (#33478)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-24 12:52:56 +07:00
dependabot[bot]
630e0e0240 chore(deps-dev): bump babel-loader from 9.2.1 to 10.0.0 in /superset-frontend (#33489)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-23 14:57:36 -06:00
dependabot[bot]
513047c3bb chore(deps): bump less-loader from 11.1.4 to 12.3.0 in /docs (#33488)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-23 14:54:30 -06:00
dependabot[bot]
d932837a3c chore(deps-dev): bump eslint from 9.17.0 to 9.27.0 in /superset-websocket (#33477)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-23 14:54:15 -06:00
Richard Fogaca Nienkotter
38868f9ff4 fix(sankey): incorrect nodeValues (#33431)
Co-authored-by: richardfn <richard.fogaca@appsilon.com>
2025-05-23 14:52:58 -06:00
259 changed files with 11409 additions and 7845 deletions

36
.coveragerc Normal file
View 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

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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:

View File

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

View File

@@ -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" />

View File

@@ -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/)

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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": [

View File

@@ -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"

View File

@@ -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
View 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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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",

View File

@@ -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"

View File

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

View File

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

View File

@@ -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",

View File

@@ -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"

View File

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

View File

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

View File

@@ -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'),
},
},
],
];
}

View File

@@ -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,
},

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/*
function for finding the max metric values among all series data for Radar Chart
*/
export const findGlobalMax = (
data: Record<string, unknown>[],
metrics: string[],
): number => {
if (!data?.length || !metrics?.length) return 0;
return data.reduce((globalMax, row) => {
const rowMax = metrics.reduce((max, metric) => {
const value = row[metric];
return typeof value === 'number' &&
Number.isFinite(value) &&
!Number.isNaN(value)
? Math.max(max, value)
: max;
}, 0);
return Math.max(globalMax, rowMax);
}, 0);
};
interface TooltipParams {
color: string;
name?: string;
value: number[];
}
interface TooltipMetricValue {
metric: string;
value: number;
}
export const renderNormalizedTooltip = (
params: TooltipParams,
metrics: string[],
getDenormalizedValue: (seriesName: string, value: string) => number,
metricsWithCustomBounds: Set<string>,
): string => {
const { color, name = '', value: values } = params;
const seriesName = name || 'series0';
const colorDot = `<span style="display:inline-block;margin-right:5px;border-radius:50%;width:5px;height:5px;background-color:${color}"></span>`;
// Get metric values with denormalization if needed
const metricValues: TooltipMetricValue[] = metrics.map((metric, index) => {
const value = values[index];
const originalValue = metricsWithCustomBounds.has(metric)
? value
: getDenormalizedValue(name, String(value));
return {
metric,
value: originalValue,
};
});
const tooltipRows = metricValues
.map(
({ metric, value }) => `
<div style="display:flex;">
<div>${colorDot}${metric}:</div>
<div style="font-weight:bold;margin-left:auto;">${value}</div>
</div>
`,
)
.join('');
return `
<div style="font-weight:bold;margin-bottom:5px;">${seriesName}</div>
${tooltipRows}
`;
};

View File

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

View File

@@ -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) {

View File

@@ -0,0 +1,59 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { QueryFormMetric } from '@superset-ui/core';
export const getMetricDisplayName = (
metric: QueryFormMetric,
verboseMap: Record<string, string> = {},
): string => {
// Case 1: Simple string metric - use verboseMap or the string itself
if (typeof metric === 'string') {
return verboseMap[metric] || metric;
}
// Case 2: Metric with explicit label - always prefer this if available
if (metric.label) {
return metric.label;
}
// Case 3: SIMPLE expression type (column with aggregate)
if (metric.expressionType === 'SIMPLE') {
const column = metric.column || {};
const columnName = column.column_name || '';
// Use verbose name from column if available
const displayName = column.verbose_name || columnName;
const aggregate = metric.aggregate || '';
// If the verbose map has this column, use that
if (verboseMap[columnName]) {
return `${aggregate}(${verboseMap[columnName]})`;
}
return `${aggregate}(${displayName})`;
}
// Case 4: SQL expression
if (metric.expressionType === 'SQL') {
return metric.sqlExpression || 'Custom SQL Metric';
}
// Fallback
return 'Unknown Metric';
};

View File

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

View File

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

View File

@@ -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:

View File

@@ -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;

View File

@@ -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 }) => {

View File

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

View File

@@ -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>

View File

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

View File

@@ -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

View File

@@ -67,6 +67,7 @@ export interface QueryEditor {
southPercent?: number;
updatedAt?: number;
cursorPosition?: CursorPosition;
isDataset?: boolean;
}
export type toastState = {

View File

@@ -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),

View File

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

View File

@@ -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;

View File

@@ -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}`,
});

View File

@@ -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>({

View File

@@ -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,

View File

@@ -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';

View File

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

View File

@@ -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;

View File

@@ -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[];

View File

@@ -204,3 +204,8 @@ export enum FilterPlugins {
TimeColumn = 'filter_timecolumn',
TimeGrain = 'filter_timegrain',
}
export enum Actions {
CREATE = 'create',
UPDATE = 'update',
}

View File

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

View File

@@ -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',
);
});

View File

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

View File

@@ -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>

View File

@@ -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', () => {

View File

@@ -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}
/>

View File

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

View File

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

View File

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

View File

@@ -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',
)}

View 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;

View File

@@ -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 }[];
}

View File

@@ -0,0 +1,74 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SupersetClient, t } from '@superset-ui/core';
import rison from 'rison';
import { FormValues } from './types';
export const createGroup = async (values: FormValues) => {
await SupersetClient.post({
endpoint: '/api/v1/security/groups/',
jsonPayload: { ...values, users: values.users.map(user => user.value) },
});
};
export const updateGroup = async (groupId: number, values: FormValues) => {
await SupersetClient.put({
endpoint: `/api/v1/security/groups/${groupId}`,
jsonPayload: { ...values, users: values.users.map(user => user.value) },
});
};
export const deleteGroup = async (groupId: number) =>
SupersetClient.delete({
endpoint: `/api/v1/security/groups/${groupId}`,
});
export const fetchUserOptions = async (
filterValue: string,
page: number,
pageSize: number,
addDangerToast: (msg: string) => void,
) => {
const query = rison.encode({
filter: filterValue,
page,
page_size: pageSize,
order_column: 'username',
order_direction: 'asc',
});
try {
const response = await SupersetClient.get({
endpoint: `/api/v1/security/users/?q=${query}`,
});
const results = response.json?.result || [];
return {
data: results.map((user: any) => ({
value: user.id,
label: user.username,
})),
totalCount: response.json?.count ?? 0,
};
} catch (error) {
addDangerToast(t('There was an error while fetching users'));
return { data: [], totalCount: 0 };
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,6 +45,7 @@ describe('RoleListDuplicateModal', () => {
name: 'Admin',
permission_ids: [10, 20],
user_ids: [1],
group_ids: [],
};
const mockProps = {

View File

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

View File

@@ -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'), {

View File

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

View File

@@ -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 {

View File

@@ -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}`,

View File

@@ -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,

View 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 />;

View File

@@ -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

View File

@@ -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();
},
});

View File

@@ -0,0 +1,282 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import { t, css } from '@superset-ui/core';
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
import { useListViewResource } from 'src/views/CRUD/hooks';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import ListView, { Filters, FilterOperator } from 'src/components/ListView';
// eslint-disable-next-line no-restricted-imports
import { Typography } from 'antd-v5';
import { fetchUserOptions } from 'src/features/groups/utils';
export type ActionLogObject = {
user: {
username: string;
};
action: string;
dttm: string | null;
dashboard_id?: number;
slice_id?: number;
json?: string;
duration_ms?: number;
referrer?: string;
};
const PAGE_SIZE = 25;
function ActionLogList() {
const { addDangerToast, addSuccessToast } = useToasts();
const initialSort = [{ id: 'dttm', desc: true }];
const subMenuButtons: SubMenuProps['buttons'] = [];
const {
state: {
loading,
resourceCount: LogsCount,
resourceCollection: Logs,
bulkSelectEnabled,
},
fetchData,
refreshData,
toggleBulkSelect,
} = useListViewResource<ActionLogObject>(
'log',
t('Log'),
addDangerToast,
false,
);
const filters: Filters = useMemo(
() => [
{
Header: t('Users'),
key: 'user',
id: 'user',
input: 'select',
operator: FilterOperator.RelationOneMany,
unfilteredLabel: t('All'),
fetchSelects: async (filterValue, page, pageSize) =>
fetchUserOptions(filterValue, page, pageSize, addDangerToast),
},
{
Header: t('Dashboard Id'),
key: 'dashboard_id',
id: 'dashboard_id',
input: 'search',
operator: FilterOperator.Equals,
},
{
Header: t('Slice Id'),
key: 'slice_id',
id: 'slice_id',
input: 'search',
operator: FilterOperator.Equals,
},
{
Header: t('Action'),
key: 'action',
id: 'action',
input: 'search',
operator: FilterOperator.Contains,
},
{
Header: t('JSON'),
key: 'json',
id: 'json',
input: 'search',
operator: FilterOperator.Contains,
},
{
Header: t('dttm'),
key: 'dttm',
id: 'dttm',
input: 'datetime_range',
operator: FilterOperator.Between,
dateFilterValueType: 'iso',
},
{
Header: t('Referrer'),
key: 'referrer',
id: 'referrer',
input: 'search',
operator: FilterOperator.Equals,
},
{
Header: t('Duration Ms'),
key: 'duration_ms',
id: 'duration_ms',
input: 'search',
operator: FilterOperator.Equals,
},
],
[],
);
const columns = useMemo(
() => [
{
accessor: 'action',
Header: t('Action'),
Cell: ({
row: {
original: { action },
},
}: any) => <span>{action}</span>,
},
{
accessor: 'user',
Header: t('User'),
Cell: ({
row: {
original: { user },
},
}: any) => <span>{user?.username}</span>,
},
{
accessor: 'duration_ms',
Header: t('Duration Ms'),
Cell: ({
row: {
original: { duration_ms },
},
}: any) => <span>{duration_ms}</span>,
},
{
accessor: 'dashboard_id',
Header: t('Dashboard Id'),
hidden: false,
Cell: ({
row: {
original: { dashboard_id },
},
}: any) => <span>{dashboard_id}</span>,
},
{
accessor: 'slice_id',
Header: t('Slice Id'),
hidden: false,
Cell: ({
row: {
original: { slice_id },
},
}: any) => <span>{slice_id}</span>,
},
{
accessor: 'json',
Header: t('JSON'),
Cell: ({
row: {
original: { json },
},
}: any) => (
<Typography.Text
css={css`
.antd5-typography-copy {
visibility: hidden;
}
&:hover .antd5-typography-copy {
visibility: visible;
}
`}
copyable={!!json}
ellipsis={{
tooltip: { styles: { root: { maxWidth: '900px' } }, title: json },
}}
>
{json}
</Typography.Text>
),
},
{
accessor: 'referrer',
Header: t('Referrer'),
Cell: ({
row: {
original: { referrer },
},
}: any) => (
<Typography.Text
css={css`
.antd5-typography-copy {
visibility: hidden;
}
&:hover .antd5-typography-copy {
visibility: visible;
}
`}
copyable={!!referrer}
ellipsis={{
tooltip: {
styles: { root: { maxWidth: '580px' } },
title: referrer,
},
}}
>
{referrer}
</Typography.Text>
),
},
{
accessor: 'dttm',
Header: t('Dttm'),
Cell: ({
row: {
original: { dttm },
},
}: any) => <span>{dttm}</span>,
},
],
[],
);
const emptyState = {
title: t('No Logs yet'),
image: 'filter-results.svg',
};
return (
<>
<SubMenu name={t('Action Logs')} buttons={subMenuButtons} />
<ListView<ActionLogObject>
className="action-log-view"
columns={columns}
count={LogsCount}
data={Logs}
fetchData={fetchData}
filters={filters}
initialSort={initialSort}
loading={loading}
pageSize={PAGE_SIZE}
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={toggleBulkSelect}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
emptyState={emptyState}
refreshData={refreshData}
/>
</>
);
}
export default ActionLogList;

View File

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

View File

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

View File

@@ -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>
`;

View File

@@ -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',
);
});
});

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

View 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;

View File

@@ -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' }),
]),
);
});
});

View File

@@ -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 () => {

View File

@@ -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 && (

View File

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

View 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