diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index e6543af1ec7c09fd08604dbc1cc7db4baa404f9b..124392ca87555dde2269828d0300dc87ef8a1ca6 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -41,7 +41,7 @@ body: label: Superset version options: - master / latest-dev - - "4.1.1" + - "4.1.2" - "4.0.2" validations: required: true diff --git a/.gitignore b/.gitignore index 00d77aa86ca0364679c9f0e4bdce6a8449aa635b..b8adbbc7b24459210d41d809e2e05e58262dfbff 100644 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,7 @@ ghostdriver.log testCSV.csv .terser-plugin-cache/ apache-superset-*.tar.gz* +apache_superset-*.tar.gz* release.json # Translation-related files diff --git a/CHANGELOG/4.1.1.md b/CHANGELOG/4.1.1.md new file mode 100644 index 0000000000000000000000000000000000000000..da51e78d12d2b81298aa1c95bca1791a1e78ec61 --- /dev/null +++ b/CHANGELOG/4.1.1.md @@ -0,0 +1,50 @@ +<!-- +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. +--> + +## Change Log + +### 4.1 (Fri Nov 15 22:13:57 2024 +0530) + +**Database Migrations** + +**Features** + +**Fixes** + +- [#30886](https://github.com/apache/superset/pull/30886) fix: blocks UI elements on right side (@samarsrivastav) +- [#30859](https://github.com/apache/superset/pull/30859) fix(package.json): Pin luxon version to unblock master (@geido) +- [#30588](https://github.com/apache/superset/pull/30588) fix(explore): column data type tooltip format (@mistercrunch) +- [#29911](https://github.com/apache/superset/pull/29911) fix: Rename database from 'couchbasedb' to 'couchbase' in documentation and db_engine_specs (@ayush-couchbase) +- [#30828](https://github.com/apache/superset/pull/30828) fix(TimezoneSelector): Failing unit tests due to timezone change (@geido) +- [#30875](https://github.com/apache/superset/pull/30875) fix: don't show metadata for embedded dashboards (@sadpandajoe) +- [#30851](https://github.com/apache/superset/pull/30851) fix: Graph chart colors (@michael-s-molina) +- [#29867](https://github.com/apache/superset/pull/29867) fix(capitalization): Capitalizing a button. (@rusackas) +- [#29782](https://github.com/apache/superset/pull/29782) fix(translations): Translate embedded errors (@rusackas) +- [#29772](https://github.com/apache/superset/pull/29772) fix: Fixing incomplete string escaping. (@rusackas) +- [#29725](https://github.com/apache/superset/pull/29725) fix(frontend/docker, ci): fix borked Docker build due to Lerna v8 uplift (@hainenber) + +**Others** + +- [#30576](https://github.com/apache/superset/pull/30576) chore: add link to Superset when report error (@eschutho) +- [#29786](https://github.com/apache/superset/pull/29786) refactor(Slider): Upgrade Slider to Antd 5 (@geido) +- [#29674](https://github.com/apache/superset/pull/29674) refactor(ChartCreation): Migrate tests to RTL (@rtexelm) +- [#29843](https://github.com/apache/superset/pull/29843) refactor(controls): Migrate AdhocMetricOption.test to RTL (@rtexelm) +- [#29845](https://github.com/apache/superset/pull/29845) refactor(controls): Migrate MetricDefinitionValue.test to RTL (@rtexelm) +- [#28424](https://github.com/apache/superset/pull/28424) docs: Check markdown files for bad links using linkinator (@rusackas) +- [#29768](https://github.com/apache/superset/pull/29768) docs(contributing): fix broken link to translations sub-section (@sfirke) diff --git a/CHANGELOG/4.1.2.md b/CHANGELOG/4.1.2.md new file mode 100644 index 0000000000000000000000000000000000000000..917092b0518e00c7928e362b2c32145f0a34ccf4 --- /dev/null +++ b/CHANGELOG/4.1.2.md @@ -0,0 +1,83 @@ +<!-- +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. +--> + +## Change Log + +### 4.1.2 (Fri Mar 7 13:28:05 2025 -0800) + +**Database Migrations** + +- [#32538](https://github.com/apache/superset/pull/32538) fix(migrations): Handle comparator None in old time comparison migration (@Antonio-RiveroMartnez) +- [#32155](https://github.com/apache/superset/pull/32155) fix(migrations): Handle no params in time comparison migration (@Antonio-RiveroMartnez) +- [#31185](https://github.com/apache/superset/pull/31185) fix: check for column before adding in migrations (@betodealmeida) + +**Features** + +- [#29974](https://github.com/apache/superset/pull/29974) feat(sqllab): Adds refresh button to table metadata in SQL Lab (@Usiel) + +**Fixes** + +- [#32515](https://github.com/apache/superset/pull/32515) fix(sqllab): Allow clear on schema and catalog (@justinpark) +- [#32500](https://github.com/apache/superset/pull/32500) fix: dashboard, chart and dataset import validation (@dpgaspar) +- [#31353](https://github.com/apache/superset/pull/31353) fix(sqllab): duplicate error message (@betodealmeida) +- [#31407](https://github.com/apache/superset/pull/31407) fix: Big Number side cut fixed (@fardin-developer) +- [#31480](https://github.com/apache/superset/pull/31480) fix(sunburst): Use metric label from verbose map (@gerbermichi) +- [#31427](https://github.com/apache/superset/pull/31427) fix(tags): clean up bulk create api and schema (@villebro) +- [#31334](https://github.com/apache/superset/pull/31334) fix(docs): add custom editUrl path for intro page (@dwgrossberg) +- [#31353](https://github.com/apache/superset/pull/31353) fix(sqllab): duplicate error message (@betodealmeida) +- [#31323](https://github.com/apache/superset/pull/31323) fix: Use clickhouse sqlglot dialect for YDB (@vgvoleg) +- [#31198](https://github.com/apache/superset/pull/31198) fix: add more clickhouse disallowed functions on config (@dpgaspar) +- [#31194](https://github.com/apache/superset/pull/31194) fix(embedded): Hide anchor links in embedded mode (@Vitor-Avila) +- [#31960](https://github.com/apache/superset/pull/31960) fix(sqllab): Missing allowHTML props in ResultTableExtension (@justinpark) +- [#31332](https://github.com/apache/superset/pull/31332) fix: prevent multiple pvm errors on migration (@eschutho) +- [#31437](https://github.com/apache/superset/pull/31437) fix(database import): Gracefully handle error to get catalog schemas (@Vitor-Avila) +- [#31173](https://github.com/apache/superset/pull/31173) fix: cache-warmup fails (@nsivarajan) +- [#30442](https://github.com/apache/superset/pull/30442) fix(fe/src/dashboard): optional chaining for possibly nullable parent attribute in LayoutItem type (@hainenber) +- [#31639](https://github.com/apache/superset/pull/31639) fix(sqllab): unable to update saved queries (@DamianPendrak) +- [#29898](https://github.com/apache/superset/pull/29898) fix: parse pandas pivot null values (@eschutho) +- [#31414](https://github.com/apache/superset/pull/31414) fix(Pivot Table): Fix column width to respect currency config (@Vitor-Avila) +- [#31335](https://github.com/apache/superset/pull/31335) fix(histogram): axis margin padding consistent with other graphs (@tatiana-cherne) +- [#31301](https://github.com/apache/superset/pull/31301) fix(AllEntitiesTable): show Tags (@alexandrusoare) +- [#31329](https://github.com/apache/superset/pull/31329) fix: pass string to `process_template` (@betodealmeida) +- [#31341](https://github.com/apache/superset/pull/31341) fix(pinot): remove query aliases from SELECT and ORDER BY clauses in Pinot (@yuribogomolov) +- [#31308](https://github.com/apache/superset/pull/31308) fix: annotations on horizontal bar chart (@DamianPendrak) +- [#31294](https://github.com/apache/superset/pull/31294) fix(sqllab): Remove update_saved_query_exec_info to reduce lag (@justinpark) +- [#30897](https://github.com/apache/superset/pull/30897) fix: Exception handling for SQL Lab views (@michael-s-molina) +- [#31199](https://github.com/apache/superset/pull/31199) fix(Databricks): Escape catalog and schema names in pre-queries (@Vitor-Avila) +- [#31265](https://github.com/apache/superset/pull/31265) fix(trino): db session error in handle cursor (@justinpark) +- [#31024](https://github.com/apache/superset/pull/31024) fix(dataset): use sqlglot for DML check (@betodealmeida) +- [#29885](https://github.com/apache/superset/pull/29885) fix: add mutator to get_columns_description (@eschutho) +- [#30821](https://github.com/apache/superset/pull/30821) fix: x axis title disappears when editing bar chart (@DamianPendrak) +- [#31181](https://github.com/apache/superset/pull/31181) fix: Time-series Line Chart Display unnecessary total (@michael-s-molina) +- [#31163](https://github.com/apache/superset/pull/31163) fix(Dashboard): Backward compatible shared_label_colors field (@geido) +- [#31156](https://github.com/apache/superset/pull/31156) fix: check orderby (@betodealmeida) +- [#31154](https://github.com/apache/superset/pull/31154) fix: Remove unwanted commit on Trino's handle_cursor (@michael-s-molina) +- [#31151](https://github.com/apache/superset/pull/31151) fix: Revert "feat(trino): Add functionality to upload data (#29164)" (@michael-s-molina) +- [#31031](https://github.com/apache/superset/pull/31031) fix(Dashboard): Ensure shared label colors are updated (@geido) +- [#30967](https://github.com/apache/superset/pull/30967) fix(release validation): scripts now support RSA and EDDSA keys. (@rusackas) +- [#30881](https://github.com/apache/superset/pull/30881) fix(Dashboard): Native & Cross-Filters Scoping Performance (@geido) +- [#30887](https://github.com/apache/superset/pull/30887) fix(imports): import query_context for imports with charts (@lindenh) +- [#31008](https://github.com/apache/superset/pull/31008) fix(explore): verified props is not updated (@justinpark) +- [#30646](https://github.com/apache/superset/pull/30646) fix(Dashboard): Retain colors when color scheme not set (@geido) +- [#30962](https://github.com/apache/superset/pull/30962) fix(Dashboard): Exclude edit param in async screenshot (@geido) + +**Others** + +- [#32043](https://github.com/apache/superset/pull/32043) chore: Skip the creation of secondary perms during catalog migrations (@Vitor-Avila) +- [#30865](https://github.com/apache/superset/pull/30865) docs: Updating 4.1 Release Notes (@yousoph) diff --git a/README.md b/README.md index e0bb291539903333b1b96d345c82fd4ffb9b47c5..1cc4c44a6d01e1c784e6ed3b6fdc97a830380be8 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,11 @@ under the License. # Superset [](https://opensource.org/license/apache-2-0) -[](https://github.com/apache/superset/tree/latest) +[](https://github.com/apache/superset/releases/latest) [](https://github.com/apache/superset/actions) -[](https://badge.fury.io/py/apache-superset) +[](https://badge.fury.io/py/apache_superset) [](https://codecov.io/github/apache/superset) -[](https://pypi.python.org/pypi/apache-superset) +[](https://pypi.python.org/pypi/apache_superset) [](http://bit.ly/join-superset-slack) [](https://superset.apache.org) @@ -72,9 +72,10 @@ Superset provides: ## Screenshots & Gifs **Video Overview** + <!-- File hosted here https://github.com/apache/superset-site/raw/lfs/superset-video-4k.mp4 --> -[superset-video-1080p.webm](https://github.com/user-attachments/assets/b37388f7-a971-409c-96a7-90c4e31322e6) +[superset-video-1080p.webm](https://github.com/user-attachments/assets/b37388f7-a971-409c-96a7-90c4e31322e6) <br/> @@ -156,7 +157,7 @@ Try out Superset's [quickstart](https://superset.apache.org/docs/quickstart/) gu and please read our [Slack Community Guidelines](https://github.com/apache/superset/blob/master/CODE_OF_CONDUCT.md#slack-community-guidelines) - [Join our dev@superset.apache.org Mailing list](https://lists.apache.org/list.html?dev@superset.apache.org). To join, simply send an email to [dev-subscribe@superset.apache.org](mailto:dev-subscribe@superset.apache.org) - If you want to help troubleshoot GitHub Issues involving the numerous database drivers that Superset supports, please consider adding your name and the databases you have access to on the [Superset Database Familiarity Rolodex](https://docs.google.com/spreadsheets/d/1U1qxiLvOX0kBTUGME1AHHi6Ywel6ECF8xk_Qy-V9R8c/edit#gid=0) -- Join Superset's Town Hall and [Operational Model](https://preset.io/blog/the-superset-operational-model-wants-you/) recurring meetings. Meeting info is available on the [Superset Community Calendar](https://superset.apache.org/community) +- Join Superset's Town Hall and [Operational Model](https://preset.io/blog/the-superset-operational-model-wants-you/) recurring meetings. Meeting info is available on the [Superset Community Calendar](https://superset.apache.org/community) ## Contributor Guide @@ -184,14 +185,16 @@ Understanding the Superset Points of View - [Building New Database Connectors](https://preset.io/blog/building-database-connector/) - [Create Your First Dashboard](https://superset.apache.org/docs/using-superset/creating-your-first-dashboard/) - [Comprehensive Tutorial for Contributing Code to Apache Superset - ](https://preset.io/blog/tutorial-contributing-code-to-apache-superset/) + ](https://preset.io/blog/tutorial-contributing-code-to-apache-superset/) - [Resources to master Superset by Preset](https://preset.io/resources/) - Deploying Superset + - [Official Docker image](https://hub.docker.com/r/apache/superset) - [Helm Chart](https://github.com/apache/superset/tree/master/helm/superset) - Recordings of Past [Superset Community Events](https://preset.io/events) + - [Mixed Time Series Charts](https://preset.io/events/mixed-time-series-visualization-in-superset-workshop/) - [How the Bing Team Customized Superset for the Internal Self-Serve Data & Analytics Platform](https://preset.io/events/how-the-bing-team-heavily-customized-superset-for-their-internal-data/) - [Live Demo: Visualizing MongoDB and Pinot Data using Trino](https://preset.io/events/2021-04-13-visualizing-mongodb-and-pinot-data-using-trino/) @@ -199,6 +202,7 @@ Understanding the Superset Points of View - [Building a Database Connector for Superset](https://preset.io/events/2021-02-16-building-a-database-connector-for-superset/) - Visualizations + - [Creating Viz Plugins](https://superset.apache.org/docs/contributing/creating-viz-plugins/) - [Managing and Deploying Custom Viz Plugins](https://medium.com/nmc-techblog/apache-superset-manage-custom-viz-plugins-in-production-9fde1a708e55) - [Why Apache Superset is Betting on Apache ECharts](https://preset.io/blog/2021-4-1-why-echarts/) diff --git a/RELEASING/Dockerfile.from_local_tarball b/RELEASING/Dockerfile.from_local_tarball index 6240439050a71574834883b5e63b8d5796944893..3794ed4c80aec595162d2f208bb4ebe92582aaf7 100644 --- a/RELEASING/Dockerfile.from_local_tarball +++ b/RELEASING/Dockerfile.from_local_tarball @@ -20,7 +20,7 @@ RUN useradd --user-group --create-home --no-log-init --shell /bin/bash superset # Configure environment ENV LANG=C.UTF-8 \ - LC_ALL=C.UTF-8 + LC_ALL=C.UTF-8 RUN apt-get update -y @@ -30,14 +30,14 @@ RUN apt-get install -y apt-transport-https apt-utils # Install superset dependencies # https://superset.apache.org/docs/installation/installing-superset-from-scratch RUN apt-get install -y build-essential libssl-dev \ - libffi-dev python3-dev libsasl2-dev libldap2-dev libxi-dev chromium zstd + libffi-dev python3-dev libsasl2-dev libldap2-dev libxi-dev chromium zstd # Install nodejs for custom build # https://nodejs.org/en/download/package-manager/ RUN set -eux; \ - curl -sL https://deb.nodesource.com/setup_20.x | bash -; \ - apt-get install -y nodejs; \ - node --version; + curl -sL https://deb.nodesource.com/setup_20.x | bash -; \ + apt-get install -y nodejs; \ + node --version; RUN if ! which npm; then apt-get install -y npm; fi RUN mkdir -p /home/superset @@ -50,21 +50,21 @@ ARG SUPERSET_RELEASE_RC_TARBALL # Can fetch source from svn or copy tarball from local mounted directory COPY $SUPERSET_RELEASE_RC_TARBALL ./ RUN tar -xvf *.tar.gz -WORKDIR /home/superset/apache-superset-$VERSION/superset-frontend +WORKDIR /home/superset/apache_superset-$VERSION/superset-frontend RUN npm ci \ - && npm run build \ - && rm -rf node_modules + && npm run build \ + && rm -rf node_modules -WORKDIR /home/superset/apache-superset-$VERSION +WORKDIR /home/superset/apache_superset-$VERSION RUN pip install --upgrade setuptools pip \ - && pip install -r requirements/base.txt \ - && pip install --no-cache-dir . + && pip install -r requirements/base.txt \ + && pip install --no-cache-dir . RUN flask fab babel-compile --target superset/translations ENV PATH=/home/superset/superset/bin:$PATH \ - PYTHONPATH=/home/superset/superset/ \ - SUPERSET_TESTENV=true + PYTHONPATH=/home/superset/superset/ \ + SUPERSET_TESTENV=true COPY from_tarball_entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] diff --git a/RELEASING/Dockerfile.from_svn_tarball b/RELEASING/Dockerfile.from_svn_tarball index f14754c6901d45558db853cf052c8581dfc23790..33d0e9451b0b40cda88385db2cadea6ae350edbe 100644 --- a/RELEASING/Dockerfile.from_svn_tarball +++ b/RELEASING/Dockerfile.from_svn_tarball @@ -20,7 +20,7 @@ RUN useradd --user-group --create-home --no-log-init --shell /bin/bash superset # Configure environment ENV LANG=C.UTF-8 \ - LC_ALL=C.UTF-8 + LC_ALL=C.UTF-8 RUN apt-get update -y @@ -30,14 +30,14 @@ RUN apt-get install -y apt-transport-https apt-utils # Install superset dependencies # https://superset.apache.org/docs/installation/installing-superset-from-scratch RUN apt-get install -y subversion build-essential libssl-dev \ - libffi-dev python3-dev libsasl2-dev libldap2-dev libxi-dev chromium zstd + libffi-dev python3-dev libsasl2-dev libldap2-dev libxi-dev chromium zstd # Install nodejs for custom build # https://nodejs.org/en/download/package-manager/ RUN set -eux; \ - curl -sL https://deb.nodesource.com/setup_20.x | bash -; \ - apt-get install -y nodejs; \ - node --version; + curl -sL https://deb.nodesource.com/setup_20.x | bash -; \ + apt-get install -y nodejs; \ + node --version; RUN if ! which npm; then apt-get install -y npm; fi RUN mkdir -p /home/superset @@ -49,20 +49,20 @@ ARG VERSION # Can fetch source from svn or copy tarball from local mounted directory RUN svn co https://dist.apache.org/repos/dist/dev/superset/$VERSION ./ RUN tar -xvf *.tar.gz -WORKDIR /home/superset/apache-superset-$VERSION/superset-frontend +WORKDIR /home/superset/apache_superset-$VERSION/superset-frontend RUN npm ci \ - && npm run build \ - && rm -rf node_modules + && npm run build \ + && rm -rf node_modules -WORKDIR /home/superset/apache-superset-$VERSION +WORKDIR /home/superset/apache_superset-$VERSION RUN pip install --upgrade setuptools pip \ - && pip install -r requirements/base.txt \ - && pip install --no-cache-dir . + && pip install -r requirements/base.txt \ + && pip install --no-cache-dir . RUN flask fab babel-compile --target superset/translations ENV PATH=/home/superset/superset/bin:$PATH \ - PYTHONPATH=/home/superset/superset/ + PYTHONPATH=/home/superset/superset/ COPY from_tarball_entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] diff --git a/RELEASING/README.md b/RELEASING/README.md index 7030571be627171f60c2d62d5d80e5ca2a7297d8..4468c9d27839bf517a481caacbc387db2a9fad2b 100644 --- a/RELEASING/README.md +++ b/RELEASING/README.md @@ -123,10 +123,10 @@ SUPERSET_RC=1 SUPERSET_GITHUB_BRANCH=1.5 SUPERSET_PGP_FULLNAME=villebro@apache.org SUPERSET_VERSION_RC=1.5.1rc1 -SUPERSET_RELEASE=apache-superset-1.5.1 -SUPERSET_RELEASE_RC=apache-superset-1.5.1rc1 -SUPERSET_RELEASE_TARBALL=apache-superset-1.5.1-source.tar.gz -SUPERSET_RELEASE_RC_TARBALL=apache-superset-1.5.1rc1-source.tar.gz +SUPERSET_RELEASE=apache_superset-1.5.1 +SUPERSET_RELEASE_RC=apache_superset-1.5.1rc1 +SUPERSET_RELEASE_TARBALL=apache_superset-1.5.1-source.tar.gz +SUPERSET_RELEASE_RC_TARBALL=apache_superset-1.5.1rc1-source.tar.gz SUPERSET_TMP_ASF_SITE_PATH=/tmp/incubator-superset-site-1.5.1 ------------------------------- ``` @@ -380,7 +380,7 @@ Official instructions: https://www.apache.org/info/verification.html We now have a handy script for anyone validating a release to use. The core of it is in this very folder, `verify_release.py`. Just make sure you have all three release files in the same directory (`{some version}.tar.gz`, `{some version}.tar.gz.asc` and `{some version}tar.gz.sha512`). Then you can pass this script the path to the `.gz` file like so: -`python verify_release.py ~/path/tp/apache-superset-{version/candidate}-source.tar.gz` +`python verify_release.py ~/path/tp/apache_superset-{version/candidate}-source.tar.gz` If all goes well, you will see this result in your terminal: @@ -470,7 +470,7 @@ while requesting access to push packages. ```bash twine upload dist/apache_superset-${SUPERSET_VERSION}-py3-none-any.whl -twine upload dist/apache-superset-${SUPERSET_VERSION}.tar.gz +twine upload dist/apache_superset-${SUPERSET_VERSION}.tar.gz ``` Set your username to `__token__` diff --git a/RELEASING/email_templates/announce.j2 b/RELEASING/email_templates/announce.j2 index b12a2ee5b03bae1ce8fce4d484538293524be020..01b6893fb309b5cf37ea281a6e3428657b334464 100644 --- a/RELEASING/email_templates/announce.j2 +++ b/RELEASING/email_templates/announce.j2 @@ -31,7 +31,7 @@ The official source release: https://downloads.apache.org/{{ project_module }}/{{ version }} The PyPI package: -https://pypi.org/project/apache-superset/{{ version }} +https://pypi.org/project/apache_superset/{{ version }} The CHANGELOG for the release: https://github.com/apache/{{ project_module }}/blob/{{ version }}/CHANGELOG/{{ version }}.md diff --git a/RELEASING/make_tarball.sh b/RELEASING/make_tarball.sh index 47686d440227b2a972238bd4f06960bec2ff627e..c4c53f979e3e07adb72b26468905a581993061c2 100755 --- a/RELEASING/make_tarball.sh +++ b/RELEASING/make_tarball.sh @@ -32,7 +32,7 @@ else SUPERSET_VERSION="${1}" SUPERSET_RC="${2}" SUPERSET_PGP_FULLNAME="${3}" - SUPERSET_RELEASE_RC_TARBALL="apache-superset-${SUPERSET_VERSION_RC}-source.tar.gz" + SUPERSET_RELEASE_RC_TARBALL="apache_superset-${SUPERSET_VERSION_RC}-source.tar.gz" fi SUPERSET_VERSION_RC="${SUPERSET_VERSION}rc${SUPERSET_RC}" diff --git a/RELEASING/make_tarball_entrypoint.sh b/RELEASING/make_tarball_entrypoint.sh index ffbc0ac33ca62468df7d9b411e7979076db92676..022fca294c22fb9736a92432c15379613ea63de6 100755 --- a/RELEASING/make_tarball_entrypoint.sh +++ b/RELEASING/make_tarball_entrypoint.sh @@ -22,7 +22,7 @@ if [ -z "${SUPERSET_VERSION_RC}" ] || [ -z "${SUPERSET_SVN_DEV_PATH}" ] || [ -z exit 1 fi -SUPERSET_RELEASE_RC=apache-superset-"${SUPERSET_VERSION_RC}" +SUPERSET_RELEASE_RC=apache_superset-"${SUPERSET_VERSION_RC}" SUPERSET_RELEASE_RC_TARBALL="${SUPERSET_RELEASE_RC}"-source.tar.gz SUPERSET_RELEASE_RC_BASE_PATH="${SUPERSET_SVN_DEV_PATH}"/"${SUPERSET_VERSION_RC}" SUPERSET_RELEASE_RC_TARBALL_PATH="${SUPERSET_RELEASE_RC_BASE_PATH}"/"${SUPERSET_RELEASE_RC_TARBALL}" diff --git a/RELEASING/set_release_env.sh b/RELEASING/set_release_env.sh index 3d04a76d78633acf60d3dba2023ac8f8fbbc5b91..7b297395775bca3a1df277f975b78fc9e13da25b 100755 --- a/RELEASING/set_release_env.sh +++ b/RELEASING/set_release_env.sh @@ -50,8 +50,8 @@ else export SUPERSET_GITHUB_BRANCH="${VERSION_MAJOR}.${VERSION_MINOR}" export SUPERSET_PGP_FULLNAME="${2}" export SUPERSET_VERSION_RC="${SUPERSET_VERSION}rc${VERSION_RC}" - export SUPERSET_RELEASE=apache-superset-"${SUPERSET_VERSION}" - export SUPERSET_RELEASE_RC=apache-superset-"${SUPERSET_VERSION_RC}" + export SUPERSET_RELEASE=apache_superset-"${SUPERSET_VERSION}" + export SUPERSET_RELEASE_RC=apache_superset-"${SUPERSET_VERSION_RC}" export SUPERSET_RELEASE_TARBALL="${SUPERSET_RELEASE}"-source.tar.gz export SUPERSET_RELEASE_RC_TARBALL="${SUPERSET_RELEASE_RC}"-source.tar.gz export SUPERSET_TMP_ASF_SITE_PATH="/tmp/incubator-superset-site-${SUPERSET_VERSION}" diff --git a/RELEASING/test_run_tarball.sh b/RELEASING/test_run_tarball.sh index d4c8a9c706a05120016f268231392f4a80281f4d..d28c7226bda96c4cb65a7d71382bf747ff877e6f 100755 --- a/RELEASING/test_run_tarball.sh +++ b/RELEASING/test_run_tarball.sh @@ -27,7 +27,7 @@ if [ -z "${SUPERSET_SVN_DEV_PATH}" ]; then fi if [[ -n ${1} ]] && [[ ${1} == "local" ]]; then - SUPERSET_RELEASE_RC=apache-superset-"${SUPERSET_VERSION_RC}" + SUPERSET_RELEASE_RC=apache_superset-"${SUPERSET_VERSION_RC}" SUPERSET_RELEASE_RC_TARBALL="${SUPERSET_RELEASE_RC}"-source.tar.gz SUPERSET_TARBALL_PATH="${SUPERSET_SVN_DEV_PATH}"/${SUPERSET_VERSION_RC}/${SUPERSET_RELEASE_RC_TARBALL} SUPERSET_TMP_TARBALL_FILENAME=_tmp_"${SUPERSET_VERSION_RC}".tar.gz diff --git a/RELEASING/validate_this_release.sh b/RELEASING/validate_this_release.sh index 98c502be2a242a76b266dc17cf0b2b7f9d59f86d..4942803702cb9d15b4cee2a57eb0340b2a719432 100755 --- a/RELEASING/validate_this_release.sh +++ b/RELEASING/validate_this_release.sh @@ -38,7 +38,7 @@ get_pip_command() { PYTHON=$(get_python_command) PIP=$(get_pip_command) -# Get the release directory's path. If you unzip an Apache release and just run the npm script to validate the release, this will be a file name like `apache-superset-x.x.xrcx-source.tar.gz` +# Get the release directory's path. If you unzip an Apache release and just run the npm script to validate the release, this will be a file name like `apache_superset-x.x.xrcx-source.tar.gz` RELEASE_ZIP_PATH="../../$(basename "$(dirname "$(pwd)")")-source.tar.gz" # Install dependencies from requirements.txt if the file exists diff --git a/UPDATING.md b/UPDATING.md index f0dbf1e5f0ab004e27ff45e9ae3af5e9a6691ec7..077f43cdd8992d3e0876af4ee039b6623b3e53e8 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -33,12 +33,10 @@ assists people when migrating to a new version. - [31794](https://github.com/apache/superset/pull/31794) Removed the previously deprecated `DASHBOARD_CROSS_FILTERS` feature flag - [31774](https://github.com/apache/superset/pull/31774): Fixes the spelling of the `USE-ANALAGOUS-COLORS` feature flag. Please update any scripts/configuration item to use the new/corrected `USE-ANALOGOUS-COLORS` flag spelling. - [31582](https://github.com/apache/superset/pull/31582) Removed the legacy Area, Bar, Event Flow, Heatmap, Histogram, Line, Sankey, and Sankey Loop charts. They were all automatically migrated to their ECharts counterparts with the exception of the Event Flow and Sankey Loop charts which were removed as they were not actively maintained and not widely used. If you were using the Event Flow or Sankey Loop charts, you will need to find an alternative solution. -- [31198](https://github.com/apache/superset/pull/31198) Disallows by default the use of the following ClickHouse functions: "version", "currentDatabase", "hostName". - [29798](https://github.com/apache/superset/pull/29798) Since 3.1.0, the intial schedule for an alert or report was mistakenly offset by the specified timezone's relation to UTC. The initial schedule should now begin at the correct time. - [30021](https://github.com/apache/superset/pull/30021) The `dev` layer in our Dockerfile no long includes firefox binaries, only Chromium to reduce bloat/docker-build-time. - [30099](https://github.com/apache/superset/pull/30099) Translations are no longer included in the default docker image builds. If your environment requires translations, you'll want to set the docker build arg `BUILD_TRANSACTION=true`. - [31262](https://github.com/apache/superset/pull/31262) NOTE: deprecated `pylint` in favor of `ruff` as our only python linter. Only affect development workflows positively (not the release itself). It should cover most important rules, be much faster, but some things linting rules that were enforced before may not be enforce in the exact same way as before. -- [31173](https://github.com/apache/superset/pull/31173) Modified `fetch_csrf_token` to align with HTTP standards, particularly regarding how cookies are handled. If you encounter any issues related to CSRF functionality, please report them as a new issue and reference this PR for context. - [31413](https://github.com/apache/superset/pull/31413) Enable the DATE_FORMAT_IN_EMAIL_SUBJECT feature flag to allow users to specify a date format for the email subject, which will then be replaced with the actual date. - [31385](https://github.com/apache/superset/pull/31385) Significant docker refactor, reducing access levels for the `superset` user, streamlining layer building, ... - [31503](https://github.com/apache/superset/pull/31503) Deprecating python 3.9.x support, 3.11 is now the recommended version and 3.10 is still supported over the Superset 5.0 lifecycle. @@ -48,9 +46,15 @@ assists people when migrating to a new version. - [30284](https://github.com/apache/superset/pull/30284) Deprecated GLOBAL_ASYNC_QUERIES_REDIS_CONFIG in favor of the new GLOBAL_ASYNC_QUERIES_CACHE_BACKEND configuration. To leverage Redis Sentinel, set CACHE_TYPE to RedisSentinelCache, or use RedisCache for standalone Redis - [31961](https://github.com/apache/superset/pull/31961) Upgraded React from version 16.13.1 to 17.0.2. If you are using custom frontend extensions or plugins, you may need to update them to be compatible with React 17. - [31260](https://github.com/apache/superset/pull/31260) Docker images now use `uv pip install` instead of `pip install` to manage the python envrionment. Most docker-based deployments will be affected, whether you derive one of the published images, or have custom bootstrap script that install python libraries (drivers) +- [32432](https://github.com/apache/superset/pull/31260) Moves the List Roles FAB view to the frontend and requires `FAB_ADD_SECURITY_API` to be enabled in the configuration and `superset init` to be executed. ### Potential Downtime +## 4.1.2 + +- [31198](https://github.com/apache/superset/pull/31198) Disallows by default the use of the following ClickHouse functions: "version", "currentDatabase", "hostName". +- [31173](https://github.com/apache/superset/pull/31173) Modified `fetch_csrf_token` to align with HTTP standards, particularly regarding how cookies are handled. If you encounter any issues related to CSRF functionality, please report them as a new issue and reference this PR for context. + ## 4.1.0 - [29274](https://github.com/apache/superset/pull/29274): We made it easier to trigger CI on your diff --git a/docker/.env b/docker/.env index 45dcb5eb313e3788000a4984344ed1f5a03d06b2..8e9d9364bd86de6501f85a2dd9a2e607697d6c3d 100644 --- a/docker/.env +++ b/docker/.env @@ -62,7 +62,6 @@ MAPBOX_API_KEY='' # Make sure you set this to a unique secure random value on production SUPERSET_SECRET_KEY=TEST_NON_DEV_SECRET - ENABLE_PLAYWRIGHT=false PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true BUILD_SUPERSET_FRONTEND_IN_DOCKER=true diff --git a/docs/docs/configuration/databases.mdx b/docs/docs/configuration/databases.mdx index d30d4c2e39cfb7d596aa6edbbefb3a067ada3d07..e74c1e535a88050bc09ae4ea5d72d54cc7e04f5f 100644 --- a/docs/docs/configuration/databases.mdx +++ b/docs/docs/configuration/databases.mdx @@ -72,7 +72,7 @@ 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}` | | [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` | diff --git a/docs/docs/configuration/networking-settings.mdx b/docs/docs/configuration/networking-settings.mdx index 03b2b981fe49095ef596b9c0e21f507c8ff8a57e..4921a277d48f326295cde55319c2236c760f2f47 100644 --- a/docs/docs/configuration/networking-settings.mdx +++ b/docs/docs/configuration/networking-settings.mdx @@ -11,7 +11,7 @@ version: 1 To configure CORS, or cross-origin resource sharing, the following dependency must be installed: ```python -pip install apache-superset[cors] +pip install apache_superset[cors] ``` The following keys in `superset_config.py` can be specified to configure CORS: diff --git a/docs/docs/configuration/sql-templating.mdx b/docs/docs/configuration/sql-templating.mdx index 0e618fd9c19a4685948d602f08217df8840a6013..dcd5bb0869b6b71ff19d1d2a4cbb45ee74dc8890 100644 --- a/docs/docs/configuration/sql-templating.mdx +++ b/docs/docs/configuration/sql-templating.mdx @@ -220,6 +220,36 @@ cache key by adding the following parameter to your Jinja code: {{ current_user_email(add_to_cache_keys=False) }} ``` +**Current User Roles** + +The `{{ current_user_roles() }}` macro returns an array of roles for the logged in user. + +If you have caching enabled in your Superset configuration, then by default the roles value 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. + +You can disable the inclusion of the roles value in the calculation of the +cache key by adding the following parameter to your Jinja code: + +```python +{{ current_user_roles(add_to_cache_keys=False) }} +``` + +You can json-stringify the array by adding `|tojson` to your Jinja code: +```python +{{ current_user_roles()|tojson }} +``` + +You can use the `|where_in` filter to use your roles in a SQL statement. For example, if `current_user_roles()` returns `['admin', 'viewer']`, the following template: +```python +SELECT * FROM users WHERE role IN {{ current_user_roles()|where_in }} +``` + +Will be rendered as: +```sql +SELECT * FROM users WHERE role IN ('admin', 'viewer') +``` + **Custom URL Parameters** The `{{ url_param('custom_variable') }}` macro lets you define arbitrary URL @@ -461,3 +491,37 @@ This macro avoids copy/paste, allowing users to centralize the metric definition The `dataset_id` parameter is optional, and if not provided Superset will use the current dataset from context (for example, when using this macro in the Chart Builder, by default the `macro_key` will be searched in the dataset powering the chart). The parameter can be used in SQL Lab, or when fetching a metric from another dataset. + +## Available Filters + +Superset supports [builtin filters from the Jinja2 templating package](https://jinja.palletsprojects.com/en/stable/templates/#builtin-filters). Custom filters have also been implemented: + +**Where In** +Parses a list into a SQL-compatible statement. This is useful with macros that return an array (for example the `filter_values` macro): + +``` +Dashboard filter with "First", "Second" and "Third" options selected +{{ filter_values('column') }} => ["First", "Second", "Third"] +{{ filter_values('column')|where_in }} => ('First', 'Second', 'Third') +``` + +By default, this filter returns `()` (as a string) in case the value is null. The `default_to_none` parameter can be se to `True` to return null in this case: + +``` +Dashboard filter without any value applied +{{ filter_values('column') }} => () +{{ filter_values('column')|where_in(default_to_none=True) }} => None +``` + +**To Datetime** + +Loads a string as a `datetime` object. This is useful when performing date operations. For example: +``` +{% set from_expr = get_time_filter("dttm", strftime="%Y-%m-%d").from_expr %} +{% set to_expr = get_time_filter("dttm", strftime="%Y-%m-%d").to_expr %} +{% if (to_expr|to_datetime(format="%Y-%m-%d") - from_expr|to_datetime(format="%Y-%m-%d")).days > 100 %} + do something +{% else %} + do something else +{% endif %} +``` diff --git a/docs/docs/contributing/contributing.mdx b/docs/docs/contributing/contributing.mdx index 6ae7901874ef104537ce82db002b6fbab1365e89..109a3692df965a98343165b8d0fd15e110ae3a3d 100644 --- a/docs/docs/contributing/contributing.mdx +++ b/docs/docs/contributing/contributing.mdx @@ -26,9 +26,9 @@ More references: Here's a list of repositories that contain Superset-related packages: - [apache/superset](https://github.com/apache/superset) - is the main repository containing the `apache-superset` Python package + is the main repository containing the `apache_superset` Python package distributed on - [pypi](https://pypi.org/project/apache-superset/). This repository + [pypi](https://pypi.org/project/apache_superset/). This repository also includes Superset's main TypeScript/JavaScript bundles and react apps under the [superset-frontend](https://github.com/apache/superset/tree/master/superset-frontend) folder. diff --git a/docs/docs/installation/pypi.mdx b/docs/docs/installation/pypi.mdx index 3e01986f5cd5e33cb692d19048eedfef0b106fde..da2aabcf0b10fefe03e9c6c1eac4a13487698806 100644 --- a/docs/docs/installation/pypi.mdx +++ b/docs/docs/installation/pypi.mdx @@ -12,7 +12,7 @@ import useBaseUrl from "@docusaurus/useBaseUrl"; <img src={useBaseUrl("/img/pypi.png" )} width="150" /> <br /><br /> -This page describes how to install Superset using the `apache-superset` package [published on PyPI](https://pypi.org/project/apache-superset/). +This page describes how to install Superset using the `apache_superset` package [published on PyPI](https://pypi.org/project/apache_superset/). ## OS Dependencies @@ -124,10 +124,10 @@ command line. ### Installing and Initializing Superset -First, start by installing `apache-superset`: +First, start by installing `apache_superset`: ```bash -pip install apache-superset +pip install apache_superset ``` Then, define mandatory configurations, SECRET_KEY and FLASK_APP: diff --git a/docs/docs/installation/upgrading-superset.mdx b/docs/docs/installation/upgrading-superset.mdx index 459223385c6af653209293ba795a41221ec9aef0..38e03822dd40d4715c1f613976203f4cb590c909 100644 --- a/docs/docs/installation/upgrading-superset.mdx +++ b/docs/docs/installation/upgrading-superset.mdx @@ -32,7 +32,7 @@ docker compose up To upgrade superset in a native installation, run the following commands: ```bash -pip install apache-superset --upgrade +pip install apache_superset --upgrade ``` ## Upgrading the Metadata Database diff --git a/docs/docs/security/security.mdx b/docs/docs/security/security.mdx index f8abdfcc6db8dec27ee7510336eae7aa220c16d5..d665547760968b4ca1950111d9d59a66238b6f49 100644 --- a/docs/docs/security/security.mdx +++ b/docs/docs/security/security.mdx @@ -280,6 +280,49 @@ TALISMAN_CONFIG = { "content_security_policy": { ... ``` +#### Configuring Talisman in Superset + +Talisman settings in Superset can be modified using superset_config.py. If you need to adjust security policies, you can override the default configuration. + +Example: Overriding Talisman Configuration in superset_config.py for loading images form s3 or other external sources. + +```python +TALISMAN_CONFIG = { + "content_security_policy": { + "base-uri": ["'self'"], + "default-src": ["'self'"], + "img-src": [ + "'self'", + "blob:", + "data:", + "https://apachesuperset.gateway.scarf.sh", + "https://static.scarf.sh/", + # "https://cdn.brandfolder.io", # Uncomment when SLACK_ENABLE_AVATARS is True # noqa: E501 + "ows.terrestris.de", + "aws.s3.com", # Add Your Bucket or external data source + ], + "worker-src": ["'self'", "blob:"], + "connect-src": [ + "'self'", + "https://api.mapbox.com", + "https://events.mapbox.com", + ], + "object-src": "'none'", + "style-src": [ + "'self'", + "'unsafe-inline'", + ], + "script-src": ["'self'", "'strict-dynamic'"], + }, + "content_security_policy_nonce_in": ["script-src"], + "force_https": False, + "session_cookie_secure": False, +} +``` + +# For more information on setting up Talisman, please refer to +https://superset.apache.org/docs/configuration/networking-settings/#changing-flask-talisman-csp + ### Reporting Security Vulnerabilities Apache Software Foundation takes a rigorous standpoint in annihilating the security issues in its diff --git a/docs/docs/using-superset/creating-your-first-dashboard.mdx b/docs/docs/using-superset/creating-your-first-dashboard.mdx index 8a52258fe7bc5a384e66aca3339189ba192b6e5f..a976e4b60ddce824e71ab5a98f517fffb9d09dc9 100644 --- a/docs/docs/using-superset/creating-your-first-dashboard.mdx +++ b/docs/docs/using-superset/creating-your-first-dashboard.mdx @@ -12,8 +12,12 @@ import useBaseUrl from "@docusaurus/useBaseUrl"; This section is focused on documentation for end-users who will be using Superset for the data analysis and exploration workflow (data analysts, business analysts, data -scientists, etc). In addition to this site, [Preset.io](http://preset.io/) maintains an updated set of end-user +scientists, etc). + +:::tip +In addition to this site, [Preset.io](http://preset.io/) maintains an updated set of end-user documentation at [docs.preset.io](https://docs.preset.io/). +::: This tutorial targets someone who wants to create charts and dashboards in Superset. We’ll show you how to connect Superset to a new database and configure a table in that database for analysis. @@ -175,23 +179,36 @@ into a position you like onto the underlying grid. Congrats! You’ve successfully linked, analyzed, and visualized data in Superset. There are a wealth of other table configuration and visualization options, so please start exploring and creating -slices and dashboards of your own - -ֿ +slices and dashboards of your own. ### Manage access to Dashboards -Access to dashboards is managed via owners (users that have edit permissions to the dashboard) +Access to dashboards is managed via owners (users that have edit permissions to the dashboard). -Non-owner users access can be managed two different ways: +Non-owner users access can be managed in two different ways. The dashboard needs to be published to be visible to other users. -1. Dataset permissions - if you add to the relevant role permissions to datasets it automatically grants implicit access to all dashboards that uses those permitted datasets -2. Dashboard roles - if you enable **DASHBOARD_RBAC** [feature flag](/docs/configuration/configuring-superset#feature-flags) then you be able to manage which roles can access the dashboard +1. Dataset permissions - if you add to the relevant role permissions to datasets it automatically grants implicit access to all dashboards that uses those permitted datasets. +2. Dashboard roles - if you enable [**DASHBOARD_RBAC** feature flag](/docs/configuration/configuring-superset#feature-flags) then you will be able to manage which roles can access the dashboard - Granting a role access to a dashboard will bypass dataset level checks. Having dashboard access implicitly grants read access to all the featured charts in the dashboard, and thereby also all the associated datasets. - If no roles are specified for a dashboard, regular **Dataset permissions** will apply. <img src={useBaseUrl("/img/tutorial/tutorial_dashboard_access.png" )} /> +### Publishing a Dashboard + +If you would like to make your dashboard available to other users, click on the `Draft` button next to the +title of your dashboard. + +<img src={useBaseUrl("/img/tutorial/publish_button_dashboard.png" )} /> + +:::warning +Draft dashboards are only visible to the dashboard owners and admins. Published dashboards are visible to all users with access to the underlying datasets or if RBAC is enabled, to the roles that have been granted access to the dashboard. +::: + +### Mark a Dashboard as Favorite + +You can mark a dashboard as a favorite by clicking on the star icon next to the title of your dashboard. This makes it easier to find it in the list of dashboards or on the home page. + ### Customizing dashboard The following URL parameters can be used to modify how the dashboard is rendered: diff --git a/docs/package.json b/docs/package.json index 7bb75fb34a095ca88f5803ff9b57fb5d6f91cef7..b100ce41f61250c557840d8fd28100d4e383b0c2 100644 --- a/docs/package.json +++ b/docs/package.json @@ -25,7 +25,7 @@ "@emotion/styled": "^10.0.27", "@saucelabs/theme-github-codeblock": "^0.3.0", "@superset-ui/style": "^0.14.23", - "antd": "^5.24.2", + "antd": "^5.24.5", "docusaurus-plugin-less": "^2.0.2", "less": "^4.2.2", "less-loader": "^11.0.0", @@ -34,7 +34,7 @@ "react-dom": "^18.3.1", "react-github-btn": "^1.4.0", "react-svg-pan-zoom": "^3.13.1", - "swagger-ui-react": "^5.20.0" + "swagger-ui-react": "^5.20.2" }, "devDependencies": { "@docusaurus/module-type-aliases": "^3.7.0", @@ -43,7 +43,7 @@ "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", "eslint": "^8.0.0", - "eslint-config-prettier": "^10.0.2", + "eslint-config-prettier": "^10.1.1", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-react": "^7.0.0", "prettier": "^2.0.0", diff --git a/docs/src/styles/custom.css b/docs/src/styles/custom.css index 40ac1ad8e2314557378ea7e04321c8c15999994a..7f18f0d3abc9e68e5e75efc6e4e35061333b3c11 100644 --- a/docs/src/styles/custom.css +++ b/docs/src/styles/custom.css @@ -58,7 +58,6 @@ ul.dropdown__menu svg { --ifm-code-font-size: 95%; --ifm-menu-link-padding-vertical: 12px; --doc-sidebar-width: 350px !important; - --ifm-navbar-height: none; --ifm-font-family-base: Roboto; --ifm-footer-background-color: #173036; --ifm-footer-color: #87939a; diff --git a/docs/static/img/tutorial/publish_button_dashboard.png b/docs/static/img/tutorial/publish_button_dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..c20a097dfdc066bb349171a60932b510da905847 Binary files /dev/null and b/docs/static/img/tutorial/publish_button_dashboard.png differ diff --git a/docs/yarn.lock b/docs/yarn.lock index 2d84567ad48638a97e43818247c7a23bfb961177..faa7ccc96a6574ac26f66b8c2e9ce3287add7d6f 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -1366,10 +1366,10 @@ resolved "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz" integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== -"@babel/runtime-corejs3@^7.20.7", "@babel/runtime-corejs3@^7.22.15", "@babel/runtime-corejs3@^7.22.6", "@babel/runtime-corejs3@^7.26.7": - version "7.26.10" - resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.26.10.tgz#5a3185ca2813f8de8ae68622572086edf5cf51f2" - integrity sha512-uITFQYO68pMEYR46AHgQoyBg7KPPJDAbGn4jUTIRgCFJIp88MIBUianVOplhZDEec07bp9zIyr4Kp0FCyQzmWg== +"@babel/runtime-corejs3@^7.20.7", "@babel/runtime-corejs3@^7.22.15", "@babel/runtime-corejs3@^7.22.6", "@babel/runtime-corejs3@^7.26.10": + version "7.27.0" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.27.0.tgz#c766df350ec7a2caf3ed64e3659b100954589413" + integrity sha512-UWjX6t+v+0ckwZ50Y5ShZLnlk95pP5MyW/pon9tiYzl3+18pkTHTFNTKr7rQbfRXPkowt2QAn30o1b6oswszew== dependencies: core-js-pure "^3.30.2" regenerator-runtime "^0.14.0" @@ -2491,7 +2491,34 @@ ramda-adjunct "^5.0.0" unraw "^3.0.0" -"@swagger-api/apidom-core@>=1.0.0-beta.12 <1.0.0-rc.0", "@swagger-api/apidom-core@^1.0.0-beta.12", "@swagger-api/apidom-core@^1.0.0-beta.3": +"@swagger-api/apidom-ast@^1.0.0-beta.30": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ast/-/apidom-ast-1.0.0-beta.30.tgz#88c25259a4a0b8fd9fa2106634089a927fd1d2d9" + integrity sha512-5Wj3zdt0dxS9ERVk4qSuqDIsMQ8dP2vop8b494OpJ/O2W261yCV39Z+vN+PqeJ2NiKDRMlJ+QoQ1uVfKwEo8Kg== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-error" "^1.0.0-beta.30" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + unraw "^3.0.0" + +"@swagger-api/apidom-core@>=1.0.0-beta.13 <1.0.0-rc.0", "@swagger-api/apidom-core@^1.0.0-beta.30": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-core/-/apidom-core-1.0.0-beta.30.tgz#e0f52d343c109304878e5bf119a9fc297f5a09dc" + integrity sha512-pDnUhXIKKUvmeezQfwKLL05rkOH1L7ueiy5ja5ob9y2w4r+HXDID7qHtDGeRxKZoIt4E3Sd1K37OjcE9fNcknQ== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-ast" "^1.0.0-beta.30" + "@swagger-api/apidom-error" "^1.0.0-beta.30" + "@types/ramda" "~0.30.0" + minim "~0.23.8" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + short-unique-id "^5.0.2" + ts-mixer "^6.0.3" + +"@swagger-api/apidom-core@^1.0.0-beta.12", "@swagger-api/apidom-core@^1.0.0-beta.3": version "1.0.0-beta.12" resolved "https://registry.npmjs.org/@swagger-api/apidom-core/-/apidom-core-1.0.0-beta.12.tgz" integrity sha512-CAr6aSk9l9ZJUneHpmwk4Br0NZhFLy2QRHoPmr2pWMlAn+0YC4eRYtwOEB8PVsCmP83D4MiXU5zi6cOZyV/cVw== @@ -2506,14 +2533,33 @@ short-unique-id "^5.0.2" ts-mixer "^6.0.3" -"@swagger-api/apidom-error@>=1.0.0-beta.12 <1.0.0-rc.0", "@swagger-api/apidom-error@^1.0.0-beta.12", "@swagger-api/apidom-error@^1.0.0-beta.3", "@swagger-api/apidom-error@^1.0.0-beta.3 <1.0.0-rc.0": +"@swagger-api/apidom-error@>=1.0.0-beta.13 <1.0.0-rc.0", "@swagger-api/apidom-error@^1.0.0-beta.30": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-error/-/apidom-error-1.0.0-beta.30.tgz#97fdc0eedc9cb28f8f76b29933b7e0b8f784740e" + integrity sha512-hVDx0kUF1DTyaEXwmsF3wpJClEfnH0pxjEubqtvHpjjeTMgZzmKc5azbYtvgBX3uUpGHyQZyG/O9g94/wIhhMA== + dependencies: + "@babel/runtime-corejs3" "^7.20.7" + +"@swagger-api/apidom-error@^1.0.0-beta.12", "@swagger-api/apidom-error@^1.0.0-beta.3", "@swagger-api/apidom-error@^1.0.0-beta.3 <1.0.0-rc.0": version "1.0.0-beta.12" resolved "https://registry.npmjs.org/@swagger-api/apidom-error/-/apidom-error-1.0.0-beta.12.tgz" integrity sha512-p74a/8GgitGIYvjD5WmROEHv2bGCnDKug3QpJvC5+g36ErZQp428+fK5hhfKQuCo0rjD2fZvs27S17Zh8y0zFw== dependencies: "@babel/runtime-corejs3" "^7.20.7" -"@swagger-api/apidom-json-pointer@>=1.0.0-beta.12 <1.0.0-rc.0", "@swagger-api/apidom-json-pointer@^1.0.0-beta.12", "@swagger-api/apidom-json-pointer@^1.0.0-beta.3 <1.0.0-rc.0": +"@swagger-api/apidom-json-pointer@>=1.0.0-beta.13 <1.0.0-rc.0", "@swagger-api/apidom-json-pointer@^1.0.0-beta.30": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-1.0.0-beta.30.tgz#7dc9d17190924bd848661961b85569e854cffdee" + integrity sha512-G+BDNXU/ARJCbJiFq1A6dh6pNDDp1J0jPfKeIHjsD8aZoRdpJC0F3F7onm8TjQm2cnvAi4B7vPOKzjWrYN1VWw== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.0.0-beta.30" + "@swagger-api/apidom-error" "^1.0.0-beta.30" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-json-pointer@^1.0.0-beta.12", "@swagger-api/apidom-json-pointer@^1.0.0-beta.3 <1.0.0-rc.0": version "1.0.0-beta.12" resolved "https://registry.npmjs.org/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-1.0.0-beta.12.tgz" integrity sha512-JuCqMVfDSWJ7JcdPjYgGjNlqjmKQwxuQh7uKKBLTpNccmXYT+x7WemPuzcWjVVHDd5plw8yQ0YvaU0HlqjS1mA== @@ -2539,6 +2585,19 @@ ramda-adjunct "^5.0.0" ts-mixer "^6.0.3" +"@swagger-api/apidom-ns-arazzo-1@^1.0.0-beta.3 <1.0.0-rc.0", "@swagger-api/apidom-ns-arazzo-1@^1.0.0-beta.30": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-arazzo-1/-/apidom-ns-arazzo-1-1.0.0-beta.30.tgz#3f2f8a9ed5d2c8c10157981231afd6032e20b6b9" + integrity sha512-HpszcpuDlSOXWruHzasR64L8640VHVDuy8xXJrhx1iBu+gDHriOM8gbh8jQgWST91H0smtPeTG9WV1/h6frhRw== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.0.0-beta.30" + "@swagger-api/apidom-ns-json-schema-2020-12" "^1.0.0-beta.30" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.3" + "@swagger-api/apidom-ns-asyncapi-2@^1.0.0-beta.3", "@swagger-api/apidom-ns-asyncapi-2@^1.0.0-beta.3 <1.0.0-rc.0": version "1.0.0-beta.3" resolved "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-2/-/apidom-ns-asyncapi-2-1.0.0-beta.3.tgz" @@ -2566,6 +2625,20 @@ ramda-adjunct "^5.0.0" ts-mixer "^6.0.4" +"@swagger-api/apidom-ns-json-schema-2019-09@^1.0.0-beta.30": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-2019-09/-/apidom-ns-json-schema-2019-09-1.0.0-beta.30.tgz#2467cc1d780f7a20442b8892d77dd9a25147031c" + integrity sha512-HZL76SJaUDmL1GuFcev23UX1vVuxSHIED3vvKso+k3KWNfVWZJrr7GX1ELJx84fWW8g3b5S5+nyz5q1ApT084A== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.0.0-beta.30" + "@swagger-api/apidom-error" "^1.0.0-beta.30" + "@swagger-api/apidom-ns-json-schema-draft-7" "^1.0.0-beta.30" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.4" + "@swagger-api/apidom-ns-json-schema-2020-12@^1.0.0-beta.12": version "1.0.0-beta.12" resolved "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-2020-12/-/apidom-ns-json-schema-2020-12-1.0.0-beta.12.tgz" @@ -2580,6 +2653,20 @@ ramda-adjunct "^5.0.0" ts-mixer "^6.0.4" +"@swagger-api/apidom-ns-json-schema-2020-12@^1.0.0-beta.30": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-2020-12/-/apidom-ns-json-schema-2020-12-1.0.0-beta.30.tgz#7247885d81a7639b695687d1912cb1c66ea5897c" + integrity sha512-D2adAcu/ISoBe0zRbcX0HyaDvWoMhmaL8iPR4pvjLY7soB2tCR4uLEzAkqPa2zaOKBRA2ziF74aNKrKbM5sX8w== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.0.0-beta.30" + "@swagger-api/apidom-error" "^1.0.0-beta.30" + "@swagger-api/apidom-ns-json-schema-2019-09" "^1.0.0-beta.30" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.4" + "@swagger-api/apidom-ns-json-schema-draft-4@^1.0.0-beta.12", "@swagger-api/apidom-ns-json-schema-draft-4@^1.0.0-beta.3": version "1.0.0-beta.12" resolved "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-1.0.0-beta.12.tgz" @@ -2593,6 +2680,19 @@ ramda-adjunct "^5.0.0" ts-mixer "^6.0.4" +"@swagger-api/apidom-ns-json-schema-draft-4@^1.0.0-beta.30": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-1.0.0-beta.30.tgz#c87a9d2c92236098e78948594fdf3b24d1f77d07" + integrity sha512-u5YMIw/g74Z59wPBFS2A2LaheC+EEqRcbpUQOApTvb6zjW+xWxbCuKV1ypzIaVDDPIry8e3mpwjjXLj1mvad5w== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-ast" "^1.0.0-beta.30" + "@swagger-api/apidom-core" "^1.0.0-beta.30" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.4" + "@swagger-api/apidom-ns-json-schema-draft-6@^1.0.0-beta.12": version "1.0.0-beta.12" resolved "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-1.0.0-beta.12.tgz" @@ -2607,6 +2707,20 @@ ramda-adjunct "^5.0.0" ts-mixer "^6.0.4" +"@swagger-api/apidom-ns-json-schema-draft-6@^1.0.0-beta.30": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-1.0.0-beta.30.tgz#980bce110261b9554bdea19bf3ac01d0e1d43c07" + integrity sha512-/Mp11+tBKTN6XnpOiQo/cKnqmvfJhdCniHCK6Bg8wpCI3dMi+nSSpIYgWEPVQfNsLtf/PaYegrtYY56W4UzNRw== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.0.0-beta.30" + "@swagger-api/apidom-error" "^1.0.0-beta.30" + "@swagger-api/apidom-ns-json-schema-draft-4" "^1.0.0-beta.30" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.4" + "@swagger-api/apidom-ns-json-schema-draft-7@^1.0.0-beta.12", "@swagger-api/apidom-ns-json-schema-draft-7@^1.0.0-beta.3": version "1.0.0-beta.12" resolved "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-1.0.0-beta.12.tgz" @@ -2621,6 +2735,20 @@ ramda-adjunct "^5.0.0" ts-mixer "^6.0.4" +"@swagger-api/apidom-ns-json-schema-draft-7@^1.0.0-beta.30": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-1.0.0-beta.30.tgz#a1a665ff33f71679085f60ea444b46c257b2650e" + integrity sha512-6sZ0LLYnEz9KXtt9xTRSc0EORBl5Fj3LUbfabUjqLQZGldsJWU+3TTQ4XtzFFHlan7z2WYyALKP7iP+b60XbPg== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.0.0-beta.30" + "@swagger-api/apidom-error" "^1.0.0-beta.30" + "@swagger-api/apidom-ns-json-schema-draft-6" "^1.0.0-beta.30" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.4" + "@swagger-api/apidom-ns-openapi-2@^1.0.0-beta.3", "@swagger-api/apidom-ns-openapi-2@^1.0.0-beta.3 <1.0.0-rc.0": version "1.0.0-beta.3" resolved "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-2/-/apidom-ns-openapi-2-1.0.0-beta.3.tgz" @@ -2649,7 +2777,37 @@ ramda-adjunct "^5.0.0" ts-mixer "^6.0.3" -"@swagger-api/apidom-ns-openapi-3-1@>=1.0.0-beta.12 <1.0.0-rc.0", "@swagger-api/apidom-ns-openapi-3-1@^1.0.0-beta.3", "@swagger-api/apidom-ns-openapi-3-1@^1.0.0-beta.3 <1.0.0-rc.0": +"@swagger-api/apidom-ns-openapi-3-0@^1.0.0-beta.30": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-openapi-3-0/-/apidom-ns-openapi-3-0-1.0.0-beta.30.tgz#611709a24279435a9cf8f98552b6fba3991da031" + integrity sha512-7bz6kCgjStTKGGI4wBP2ho574lyfjH5EDPPuXhkwmAG2mOn9MZezlQhsbdo3B+vbi/58mqQb2XCoB4aeP1F+GQ== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.0.0-beta.30" + "@swagger-api/apidom-error" "^1.0.0-beta.30" + "@swagger-api/apidom-ns-json-schema-draft-4" "^1.0.0-beta.30" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.3" + +"@swagger-api/apidom-ns-openapi-3-1@>=1.0.0-beta.13 <1.0.0-rc.0": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-1.0.0-beta.30.tgz#a43fe94c57ec576c8b4a5ebeb7c2a35683ebf84b" + integrity sha512-pq2jxSp0I6xnGzyAiEXWYMuurp8H7TlOQ6Ijr/XX54gNmaIK+yQ3HXc7S6FZx+B2kQx03Tb8Y8O7L7J7YnmFiA== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-ast" "^1.0.0-beta.30" + "@swagger-api/apidom-core" "^1.0.0-beta.30" + "@swagger-api/apidom-json-pointer" "^1.0.0-beta.30" + "@swagger-api/apidom-ns-json-schema-2020-12" "^1.0.0-beta.30" + "@swagger-api/apidom-ns-openapi-3-0" "^1.0.0-beta.30" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.3" + +"@swagger-api/apidom-ns-openapi-3-1@^1.0.0-beta.3", "@swagger-api/apidom-ns-openapi-3-1@^1.0.0-beta.3 <1.0.0-rc.0": version "1.0.0-beta.12" resolved "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-1.0.0-beta.12.tgz" integrity sha512-IayaLSawWo5rAyM2nRY6faTfK8cJQ+mGGR94NOmsjcUQw9IljY9uX7PXj3izOdFlXFYjgR1P+mIhuuXyDuw4qg== @@ -2665,19 +2823,6 @@ ramda-adjunct "^5.0.0" ts-mixer "^6.0.3" -"@swagger-api/apidom-ns-workflows-1@^1.0.0-beta.3", "@swagger-api/apidom-ns-workflows-1@^1.0.0-beta.3 <1.0.0-rc.0": - version "1.0.0-beta.3" - resolved "https://registry.npmjs.org/@swagger-api/apidom-ns-workflows-1/-/apidom-ns-workflows-1-1.0.0-beta.3.tgz" - integrity sha512-+7i8CZAC+TypSYuxTtwXH2qIyQC1ATn8r+1pW4NWCs4F2Yr4K2gGG4ZmOE6ckNa+Q53yyx+Spt7xhLfZDJZp/w== - dependencies: - "@babel/runtime-corejs3" "^7.20.7" - "@swagger-api/apidom-core" "^1.0.0-beta.3" - "@swagger-api/apidom-ns-openapi-3-1" "^1.0.0-beta.3" - "@types/ramda" "~0.30.0" - ramda "~0.30.0" - ramda-adjunct "^5.0.0" - ts-mixer "^6.0.3" - "@swagger-api/apidom-parser-adapter-api-design-systems-json@^1.0.0-beta.3 <1.0.0-rc.0": version "1.0.0-beta.3" resolved "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-json/-/apidom-parser-adapter-api-design-systems-json-1.0.0-beta.3.tgz" @@ -2704,6 +2849,32 @@ ramda "~0.30.0" ramda-adjunct "^5.0.0" +"@swagger-api/apidom-parser-adapter-arazzo-json-1@^1.0.0-beta.3 <1.0.0-rc.0": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-arazzo-json-1/-/apidom-parser-adapter-arazzo-json-1-1.0.0-beta.30.tgz#3250aa29e0b423bfdf59fd355c8927a6588ee5e2" + integrity sha512-SZajkrTJ7c1I9CI3gnsdHZCQFSIyQ2H/lkWDjA/drZkRcfbR1CTbR2q0BGGlV5Y+nFHBxjRNpPbYbZrqh0WV4w== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.0.0-beta.30" + "@swagger-api/apidom-ns-arazzo-1" "^1.0.0-beta.30" + "@swagger-api/apidom-parser-adapter-json" "^1.0.0-beta.30" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-parser-adapter-arazzo-yaml-1@^1.0.0-beta.3 <1.0.0-rc.0": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-arazzo-yaml-1/-/apidom-parser-adapter-arazzo-yaml-1-1.0.0-beta.30.tgz#54ab0622a73d8ecc3190a3f1d07c4b994903536a" + integrity sha512-T+N1ix+V5IpOWMFcamQRI50830JayD1gifnRm+mVeWJKMzp+xm08bnO8NiR9LQ2SKJZ6FWYM38oG2tAt0Lwxcg== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.0.0-beta.30" + "@swagger-api/apidom-ns-arazzo-1" "^1.0.0-beta.30" + "@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.0.0-beta.30" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + "@swagger-api/apidom-parser-adapter-asyncapi-json-2@^1.0.0-beta.3 <1.0.0-rc.0": version "1.0.0-beta.3" resolved "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-2/-/apidom-parser-adapter-asyncapi-json-2-1.0.0-beta.3.tgz" @@ -2746,6 +2917,22 @@ tree-sitter-json "=0.24.8" web-tree-sitter "=0.24.3" +"@swagger-api/apidom-parser-adapter-json@^1.0.0-beta.30": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-json/-/apidom-parser-adapter-json-1.0.0-beta.30.tgz#2febfadd7e3e4ca755fcf954d44a01ee9e66e2ab" + integrity sha512-cciT19OOXafwBnXe9KFVwUGEVu4Zrvb4k12TYNlNqzVg1xA9pBc3Ywq5EgHIhiiQOLY3fILr0fr6B36N6irN2Q== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-ast" "^1.0.0-beta.30" + "@swagger-api/apidom-core" "^1.0.0-beta.30" + "@swagger-api/apidom-error" "^1.0.0-beta.30" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + tree-sitter "=0.22.1" + tree-sitter-json "=0.24.8" + web-tree-sitter "=0.24.5" + "@swagger-api/apidom-parser-adapter-openapi-json-2@^1.0.0-beta.3 <1.0.0-rc.0": version "1.0.0-beta.3" resolved "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-2/-/apidom-parser-adapter-openapi-json-2-1.0.0-beta.3.tgz" @@ -2824,32 +3011,6 @@ ramda "~0.30.0" ramda-adjunct "^5.0.0" -"@swagger-api/apidom-parser-adapter-workflows-json-1@^1.0.0-beta.3 <1.0.0-rc.0": - version "1.0.0-beta.3" - resolved "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-workflows-json-1/-/apidom-parser-adapter-workflows-json-1-1.0.0-beta.3.tgz" - integrity sha512-OsKz09YcfQfTbiNZueTLHBrn7umnMjtuN0ZzuNiBs5txaLS196grpzyTiG+4UJ1zIWvjvZmLZEbQqbKZ9qTw8A== - dependencies: - "@babel/runtime-corejs3" "^7.20.7" - "@swagger-api/apidom-core" "^1.0.0-beta.3" - "@swagger-api/apidom-ns-workflows-1" "^1.0.0-beta.3" - "@swagger-api/apidom-parser-adapter-json" "^1.0.0-beta.3" - "@types/ramda" "~0.30.0" - ramda "~0.30.0" - ramda-adjunct "^5.0.0" - -"@swagger-api/apidom-parser-adapter-workflows-yaml-1@^1.0.0-beta.3 <1.0.0-rc.0": - version "1.0.0-beta.3" - resolved "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-workflows-yaml-1/-/apidom-parser-adapter-workflows-yaml-1-1.0.0-beta.3.tgz" - integrity sha512-IifK3T6UtqBkIoHOQe6QRGpFU9LFqmJ5T1JzbWnVX+gazoVE+N9ZkFWQfb9pKCaCfAwPVp+vai6bQ2eUsGh4CA== - dependencies: - "@babel/runtime-corejs3" "^7.20.7" - "@swagger-api/apidom-core" "^1.0.0-beta.3" - "@swagger-api/apidom-ns-workflows-1" "^1.0.0-beta.3" - "@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.0.0-beta.3" - "@types/ramda" "~0.30.0" - ramda "~0.30.0" - ramda-adjunct "^5.0.0" - "@swagger-api/apidom-parser-adapter-yaml-1-2@^1.0.0-beta.3", "@swagger-api/apidom-parser-adapter-yaml-1-2@^1.0.0-beta.3 <1.0.0-rc.0": version "1.0.0-beta.3" resolved "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-yaml-1-2/-/apidom-parser-adapter-yaml-1-2-1.0.0-beta.3.tgz" @@ -2866,15 +3027,31 @@ tree-sitter "=0.21.1" web-tree-sitter "=0.24.3" -"@swagger-api/apidom-reference@>=1.0.0-beta.12 <1.0.0-rc.0": - version "1.0.0-beta.12" - resolved "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.0.0-beta.12.tgz" - integrity sha512-4A5dvra9NCsl9Dp3x3UyNV3tyTl1LJwvNowaLfMuY5r8jtQLzkcCW+CLPyP2Y64qeT30sklZp7/M3VVd6jKPOg== +"@swagger-api/apidom-parser-adapter-yaml-1-2@^1.0.0-beta.30": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-yaml-1-2/-/apidom-parser-adapter-yaml-1-2-1.0.0-beta.30.tgz#b48d0f8093d976f97dbc86a51fa86f84dfec8f5a" + integrity sha512-NRmQehyw4gbDzeBAl0zjyPqj4e/jNYgqnRLcOsxTKpWODud8RHBqEvju/M6iET6ru0o+A9265efFzqR9hiE0LA== dependencies: - "@babel/runtime-corejs3" "^7.20.7" - "@swagger-api/apidom-core" "^1.0.0-beta.12" + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-ast" "^1.0.0-beta.30" + "@swagger-api/apidom-core" "^1.0.0-beta.30" + "@swagger-api/apidom-error" "^1.0.0-beta.30" + "@tree-sitter-grammars/tree-sitter-yaml" "=0.7.0" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + tree-sitter "=0.22.1" + web-tree-sitter "=0.24.5" + +"@swagger-api/apidom-reference@>=1.0.0-beta.13 <1.0.0-rc.0": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-reference/-/apidom-reference-1.0.0-beta.30.tgz#231f80df4060a88391ecd7cece6c71cc9ae60544" + integrity sha512-l1MpLMlmaX+y2hra5EadfR37sAMzmEz1wZomVcnw7vJEFlLQo3WwOdFvpQemPCZ9IJHUs+5zhZ++w7z60uKpSw== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.0.0-beta.30" "@types/ramda" "~0.30.0" - axios "^1.7.4" + axios "^1.8.2" minimatch "^7.4.3" process "^0.11.10" ramda "~0.30.0" @@ -2882,13 +3059,15 @@ optionalDependencies: "@swagger-api/apidom-error" "^1.0.0-beta.3 <1.0.0-rc.0" "@swagger-api/apidom-json-pointer" "^1.0.0-beta.3 <1.0.0-rc.0" + "@swagger-api/apidom-ns-arazzo-1" "^1.0.0-beta.3 <1.0.0-rc.0" "@swagger-api/apidom-ns-asyncapi-2" "^1.0.0-beta.3 <1.0.0-rc.0" "@swagger-api/apidom-ns-openapi-2" "^1.0.0-beta.3 <1.0.0-rc.0" "@swagger-api/apidom-ns-openapi-3-0" "^1.0.0-beta.3 <1.0.0-rc.0" "@swagger-api/apidom-ns-openapi-3-1" "^1.0.0-beta.3 <1.0.0-rc.0" - "@swagger-api/apidom-ns-workflows-1" "^1.0.0-beta.3 <1.0.0-rc.0" "@swagger-api/apidom-parser-adapter-api-design-systems-json" "^1.0.0-beta.3 <1.0.0-rc.0" "@swagger-api/apidom-parser-adapter-api-design-systems-yaml" "^1.0.0-beta.3 <1.0.0-rc.0" + "@swagger-api/apidom-parser-adapter-arazzo-json-1" "^1.0.0-beta.3 <1.0.0-rc.0" + "@swagger-api/apidom-parser-adapter-arazzo-yaml-1" "^1.0.0-beta.3 <1.0.0-rc.0" "@swagger-api/apidom-parser-adapter-asyncapi-json-2" "^1.0.0-beta.3 <1.0.0-rc.0" "@swagger-api/apidom-parser-adapter-asyncapi-yaml-2" "^1.0.0-beta.3 <1.0.0-rc.0" "@swagger-api/apidom-parser-adapter-json" "^1.0.0-beta.3 <1.0.0-rc.0" @@ -2898,8 +3077,6 @@ "@swagger-api/apidom-parser-adapter-openapi-yaml-2" "^1.0.0-beta.3 <1.0.0-rc.0" "@swagger-api/apidom-parser-adapter-openapi-yaml-3-0" "^1.0.0-beta.3 <1.0.0-rc.0" "@swagger-api/apidom-parser-adapter-openapi-yaml-3-1" "^1.0.0-beta.3 <1.0.0-rc.0" - "@swagger-api/apidom-parser-adapter-workflows-json-1" "^1.0.0-beta.3 <1.0.0-rc.0" - "@swagger-api/apidom-parser-adapter-workflows-yaml-1" "^1.0.0-beta.3 <1.0.0-rc.0" "@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.0.0-beta.3 <1.0.0-rc.0" "@swaggerexpert/cookie@^2.0.2": @@ -2924,6 +3101,14 @@ node-addon-api "^8.0.0" node-gyp-build "^4.8.0" +"@tree-sitter-grammars/tree-sitter-yaml@=0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@tree-sitter-grammars/tree-sitter-yaml/-/tree-sitter-yaml-0.7.0.tgz#83995463cdeed8bb9ad2cdcbeb4d4aed9472411f" + integrity sha512-GOMIK3IaDvECD0eZEhAsLl03RMtM1E8StxuGMn6PpMKFg7jyQ+jSzxJZ4Jmc/tYitah9/AECt8o4tlRQ5yEZQg== + dependencies: + node-addon-api "^8.3.0" + node-gyp-build "^4.8.4" + "@trysound/sax@0.2.0": version "0.2.0" resolved "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz" @@ -3668,10 +3853,10 @@ ansi-styles@^6.1.0: resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz" integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== -antd@^5.24.2: - version "5.24.2" - resolved "https://registry.yarnpkg.com/antd/-/antd-5.24.2.tgz#df28583b68279821096234920584dadea6f9b283" - integrity sha512-7Z9HsE3ZIK3sE/WuUqii3w7Gl1IJuRL21sDUTtkN95JS5KhRYP8ISv7m/HxsJ3Mn/yxgojBCgLPJ212+Dn+aPw== +antd@^5.24.5: + version "5.24.5" + resolved "https://registry.yarnpkg.com/antd/-/antd-5.24.5.tgz#b0976a113163888d1477f9e666c3c23352b098e9" + integrity sha512-1lAv/G+9ewQanyoAo3JumQmIlVxwo5QwWGb6QCHYc40Cq0NxC/EzITcjsgq1PSaTUpLkKq8A2l7Fjtu47vqQBg== dependencies: "@ant-design/colors" "^7.2.0" "@ant-design/cssinjs" "^1.23.0" @@ -3688,22 +3873,22 @@ antd@^5.24.2: classnames "^2.5.1" copy-to-clipboard "^3.3.3" dayjs "^1.11.11" - rc-cascader "~3.33.0" + rc-cascader "~3.33.1" rc-checkbox "~3.5.0" rc-collapse "~3.9.0" rc-dialog "~9.6.0" rc-drawer "~7.2.0" rc-dropdown "~4.2.1" rc-field-form "~2.7.0" - rc-image "~7.11.0" - rc-input "~1.7.2" + rc-image "~7.11.1" + rc-input "~1.7.3" rc-input-number "~9.4.0" rc-mentions "~2.19.1" rc-menu "~9.16.1" rc-motion "^2.9.5" rc-notification "~5.6.3" rc-pagination "~5.1.0" - rc-picker "~4.11.2" + rc-picker "~4.11.3" rc-progress "~4.0.0" rc-rate "~2.13.1" rc-resize-observer "^1.4.3" @@ -3712,11 +3897,11 @@ antd@^5.24.2: rc-slider "~11.1.8" rc-steps "~6.0.1" rc-switch "~4.1.0" - rc-table "~7.50.3" + rc-table "~7.50.4" rc-tabs "~15.5.1" rc-textarea "~1.9.0" rc-tooltip "~6.4.0" - rc-tree "~5.13.0" + rc-tree "~5.13.1" rc-tree-select "~5.27.0" rc-upload "~4.8.1" rc-util "^5.44.4" @@ -3885,10 +4070,10 @@ available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" -axios@^1.7.4: - version "1.8.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.2.tgz#fabe06e241dfe83071d4edfbcaa7b1c3a40f7979" - integrity sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg== +axios@^1.8.2: + version "1.8.4" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.4.tgz#78990bb4bc63d2cae072952d374835950a82f447" + integrity sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw== dependencies: follow-redirects "^1.15.6" form-data "^4.0.0" @@ -5349,10 +5534,10 @@ escape-string-regexp@^5.0.0: resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz" integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== -eslint-config-prettier@^10.0.2: - version "10.0.2" - resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.0.2.tgz#47444de8aa104ce82c2f91ad2a5e96b62c01e20d" - integrity sha512-1105/17ZIMjmCOJOPNfVdbXafLCLj3hPmkmB7dLgt7XsQ/zkxSuDerE/xgO3RxoHysR1N1whmquY0lSn2O0VLg== +eslint-config-prettier@^10.1.1: + version "10.1.1" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.1.tgz#cf0ff6e5c4e7e15f129f1f1ce2a5ecba92dec132" + integrity sha512-4EQQr6wXwS+ZJSzaR5ZCrYgLxqvUjdXctaEtBqHcbkW944B1NQyO4qpdHQbXBONfwxXdkAY81HH4+LUfrg+zPw== eslint-plugin-prettier@^4.0.0: version "4.2.1" @@ -8256,6 +8441,11 @@ node-addon-api@^8.0.0, node-addon-api@^8.2.2: resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.0.tgz" integrity sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg== +node-addon-api@^8.2.1, node-addon-api@^8.3.0: + version "8.3.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-8.3.1.tgz#53bc8a4f8dbde3de787b9828059da94ba9fd4eed" + integrity sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA== + node-domexception@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz" @@ -8284,7 +8474,7 @@ node-forge@^1: resolved "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz" integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== -node-gyp-build@^4.8.0, node-gyp-build@^4.8.2: +node-gyp-build@^4.8.0, node-gyp-build@^4.8.2, node-gyp-build@^4.8.4: version "4.8.4" resolved "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz" integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== @@ -8424,16 +8614,16 @@ open@^8.0.9, open@^8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" -openapi-path-templating@^2.0.1: - version "2.1.0" - resolved "https://registry.npmjs.org/openapi-path-templating/-/openapi-path-templating-2.1.0.tgz" - integrity sha512-fLs5eJmLyU8wPRz+JSH5uLE7TE4Ohg6VHOtj0C0AlD3GTCCcw2LgKW6MSN1A8ZBKHEg2O4/d02knmVU1nvGAKQ== +openapi-path-templating@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/openapi-path-templating/-/openapi-path-templating-2.2.1.tgz#57026767530667096d33d7362382a93d75d497f6" + integrity sha512-eN14VrDvl/YyGxxrkGOHkVkWEoPyhyeydOUrbvjoz8K5eIGgELASwN1eqFOJ2CTQMGCy2EntOK1KdtJ8ZMekcg== dependencies: apg-lite "^1.0.4" -openapi-server-url-templating@^1.2.0: +openapi-server-url-templating@^1.3.0: version "1.3.0" - resolved "https://registry.npmjs.org/openapi-server-url-templating/-/openapi-server-url-templating-1.3.0.tgz" + resolved "https://registry.yarnpkg.com/openapi-server-url-templating/-/openapi-server-url-templating-1.3.0.tgz#80bc6ea5209a3c4fe9d359673ba51635676e2503" integrity sha512-DPlCms3KKEbjVQb0spV6Awfn6UWNheuG/+folQPzh/wUaKwuqvj8zt5gagD7qoyxtE03cIiKPgLFS3Q8Bz00uQ== dependencies: apg-lite "^1.0.4" @@ -9180,6 +9370,11 @@ ramda-adjunct@^5.0.0: resolved "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.0.0.tgz" integrity sha512-iEehjqp/ZGjYZybZByDaDu27c+79SE7rKDcySLdmjAwKWkz6jNhvGgZwzUGaMsij8Llp9+1N1Gy0drpAq8ZSyA== +ramda-adjunct@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz#c1281100922b03e74b1535cb9c966628697c5cc1" + integrity sha512-8qCpl2vZBXEJyNbi4zqcgdfHtcdsWjOGbiNSEnEBrM6Y0OKOT8UxJbIVGm1TIcjaSu2MxaWcgtsNlKlCk7o7qg== + ramda@^0.30.1, ramda@~0.30.0: version "0.30.1" resolved "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz" @@ -9220,10 +9415,10 @@ raw-body@2.5.2: iconv-lite "0.4.24" unpipe "1.0.0" -rc-cascader@~3.33.0: - version "3.33.0" - resolved "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.33.0.tgz" - integrity sha512-JvZrMbKBXIbEDmpIORxqvedY/bck6hGbs3hxdWT8eS9wSQ1P7//lGxbyKjOSyQiVBbgzNWriSe6HoMcZO/+0rQ== +rc-cascader@~3.33.1: + version "3.33.1" + resolved "https://registry.yarnpkg.com/rc-cascader/-/rc-cascader-3.33.1.tgz#19e01462ef5ef51b723c1f562c7b9cde4691e7ee" + integrity sha512-Kyl4EJ7ZfCBuidmZVieegcbFw0RcU5bHHSbtEdmuLYd0fYHCAiYKZ6zon7fWAVyC6rWWOOib0XKdTSf7ElC9rg== dependencies: "@babel/runtime" "^7.25.7" classnames "^2.3.1" @@ -9291,10 +9486,10 @@ rc-field-form@~2.7.0: "@rc-component/async-validator" "^5.0.3" rc-util "^5.32.2" -rc-image@~7.11.0: - version "7.11.0" - resolved "https://registry.npmjs.org/rc-image/-/rc-image-7.11.0.tgz" - integrity sha512-aZkTEZXqeqfPZtnSdNUnKQA0N/3MbgR7nUnZ+/4MfSFWPFHZau4p5r5ShaI0KPEMnNjv4kijSCFq/9wtJpwykw== +rc-image@~7.11.1: + version "7.11.1" + resolved "https://registry.yarnpkg.com/rc-image/-/rc-image-7.11.1.tgz#3ab290708dc053d3681de94186522e4e594f6772" + integrity sha512-XuoWx4KUXg7hNy5mRTy1i8c8p3K8boWg6UajbHpDXS5AlRVucNfTi5YxTtPBTBzegxAZpvuLfh3emXFt6ybUdA== dependencies: "@babel/runtime" "^7.11.2" "@rc-component/portal" "^1.0.2" @@ -9314,10 +9509,10 @@ rc-input-number@~9.4.0: rc-input "~1.7.1" rc-util "^5.40.1" -rc-input@~1.7.1, rc-input@~1.7.2: - version "1.7.2" - resolved "https://registry.npmjs.org/rc-input/-/rc-input-1.7.2.tgz" - integrity sha512-g3nYONnl4edWj2FfVoxsU3Ec4XTE+Hb39Kfh2MFxMZjp/0gGyPUgy/v7ZhS27ZxUFNkuIDYXm9PJsLyJbtg86A== +rc-input@~1.7.1, rc-input@~1.7.3: + version "1.7.3" + resolved "https://registry.yarnpkg.com/rc-input/-/rc-input-1.7.3.tgz#cb334a17b93ce985bceb243b4c111a5ed641e0e3" + integrity sha512-A5w4egJq8+4JzlQ55FfQjDnPvOaAbzwC3VLOAdOytyek3TboSOP9qxN+Gifup+shVXfvecBLBbWBpWxmk02SWQ== dependencies: "@babel/runtime" "^7.11.1" classnames "^2.2.1" @@ -9386,7 +9581,7 @@ rc-pagination@~5.1.0: classnames "^2.3.2" rc-util "^5.38.0" -rc-picker@~4.11.2: +rc-picker@~4.11.3: version "4.11.3" resolved "https://registry.yarnpkg.com/rc-picker/-/rc-picker-4.11.3.tgz#7e7e3ad83aa461c284b8391c697492d1c34d2cb8" integrity sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg== @@ -9476,10 +9671,10 @@ rc-switch@~4.1.0: classnames "^2.2.1" rc-util "^5.30.0" -rc-table@~7.50.3: - version "7.50.3" - resolved "https://registry.npmjs.org/rc-table/-/rc-table-7.50.3.tgz" - integrity sha512-Z4/zNCzjv7f/XzPRecb+vJU0DJKdsYt4YRkDzNl4G05m7JmxrKGYC2KqN1Ew6jw2zJq7cxVv3z39qyZOHMuf7A== +rc-table@~7.50.4: + version "7.50.4" + resolved "https://registry.yarnpkg.com/rc-table/-/rc-table-7.50.4.tgz#687b5bf76d1a94168f75481cbc83be9442010432" + integrity sha512-Y+YuncnQqoS5e7yHvfvlv8BmCvwDYDX/2VixTBEhkMDk9itS9aBINp4nhzXFKiBP/frG4w0pS9d9Rgisl0T1Bw== dependencies: "@babel/runtime" "^7.10.1" "@rc-component/context" "^1.4.0" @@ -9533,10 +9728,10 @@ rc-tree-select@~5.27.0: rc-tree "~5.13.0" rc-util "^5.43.0" -rc-tree@~5.13.0: - version "5.13.0" - resolved "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.0.tgz" - integrity sha512-2+lFvoVRnvHQ1trlpXMOWtF8BUgF+3TiipG72uOfhpL5CUdXCk931kvDdUkTL/IZVtNEDQKwEEmJbAYJSA5NnA== +rc-tree@~5.13.0, rc-tree@~5.13.1: + version "5.13.1" + resolved "https://registry.yarnpkg.com/rc-tree/-/rc-tree-5.13.1.tgz#f36a33a94a1282f4b09685216c01487089748910" + integrity sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A== dependencies: "@babel/runtime" "^7.10.1" classnames "2.x" @@ -10866,18 +11061,18 @@ svgo@^3.0.2, svgo@^3.2.0: csso "^5.0.5" picocolors "^1.0.0" -swagger-client@^3.34.1: - version "3.34.1" - resolved "https://registry.yarnpkg.com/swagger-client/-/swagger-client-3.34.1.tgz#e61e8120fe80addc3563e0ec5147a9ca71b6e7f4" - integrity sha512-aqk315C959936kijVpR28Q07eugElW9vp77a57hdFlQDF8Kuln7SeB1MwXnTCOQEM6/pIWYN00QlvIEwHqQkqw== +swagger-client@^3.34.4: + version "3.34.4" + resolved "https://registry.yarnpkg.com/swagger-client/-/swagger-client-3.34.4.tgz#034cb241af39b988ca10c39e7add2596c480075d" + integrity sha512-Qvtu8DtARAx5GwefA0eV1WRLa4Q9bhczrtNAsiBMOx3HkxAOczy1APQhrcblJdLys0xEGQ4xYizYFXfIL9BhpA== dependencies: "@babel/runtime-corejs3" "^7.22.15" "@scarf/scarf" "=1.4.0" - "@swagger-api/apidom-core" ">=1.0.0-beta.12 <1.0.0-rc.0" - "@swagger-api/apidom-error" ">=1.0.0-beta.12 <1.0.0-rc.0" - "@swagger-api/apidom-json-pointer" ">=1.0.0-beta.12 <1.0.0-rc.0" - "@swagger-api/apidom-ns-openapi-3-1" ">=1.0.0-beta.12 <1.0.0-rc.0" - "@swagger-api/apidom-reference" ">=1.0.0-beta.12 <1.0.0-rc.0" + "@swagger-api/apidom-core" ">=1.0.0-beta.13 <1.0.0-rc.0" + "@swagger-api/apidom-error" ">=1.0.0-beta.13 <1.0.0-rc.0" + "@swagger-api/apidom-json-pointer" ">=1.0.0-beta.13 <1.0.0-rc.0" + "@swagger-api/apidom-ns-openapi-3-1" ">=1.0.0-beta.13 <1.0.0-rc.0" + "@swagger-api/apidom-reference" ">=1.0.0-beta.13 <1.0.0-rc.0" "@swaggerexpert/cookie" "^2.0.2" deepmerge "~4.3.0" fast-json-patch "^3.0.0-1" @@ -10885,17 +11080,17 @@ swagger-client@^3.34.1: neotraverse "=0.6.18" node-abort-controller "^3.1.1" node-fetch-commonjs "^3.3.2" - openapi-path-templating "^2.0.1" - openapi-server-url-templating "^1.2.0" + openapi-path-templating "^2.2.1" + openapi-server-url-templating "^1.3.0" ramda "^0.30.1" - ramda-adjunct "^5.0.0" + ramda-adjunct "^5.1.0" -swagger-ui-react@^5.20.0: - version "5.20.0" - resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.20.0.tgz#46b7d65feefad6490e80a4c9f102e44c407c902a" - integrity sha512-txC3j+aPi6KOV7OxJvCdmeosg1oPmPdtOH2Ny3kogRCSwj9y7FF0IHv5KWhjmOaUvQa+9+XiYiw+NGQvi88pSg== +swagger-ui-react@^5.20.2: + version "5.20.2" + resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.20.2.tgz#eb9f8de2e6c916661d8033d43119f6e105ab87da" + integrity sha512-6ifaFjT02yBv1kjEivIMWxQpI7r8O7D/oA8u1JiwhTkom0dOk85lTExao5Dj5ztS6dBg6i1zm+ILhH94fF9g8Q== dependencies: - "@babel/runtime-corejs3" "^7.26.7" + "@babel/runtime-corejs3" "^7.26.10" "@scarf/scarf" "=1.4.0" base64-js "^1.5.1" classnames "^2.5.1" @@ -10923,7 +11118,7 @@ swagger-ui-react@^5.20.0: reselect "^5.1.1" serialize-error "^8.1.0" sha.js "^2.4.11" - swagger-client "^3.34.1" + swagger-client "^3.34.4" url-parse "^1.5.10" xml "=1.0.1" xml-but-prettier "^1.0.1" @@ -11043,6 +11238,14 @@ tree-sitter@=0.21.1: node-addon-api "^8.0.0" node-gyp-build "^4.8.0" +tree-sitter@=0.22.1: + version "0.22.1" + resolved "https://registry.yarnpkg.com/tree-sitter/-/tree-sitter-0.22.1.tgz#5a5296fc0898b21443657e071b050c95c0d7afbd" + integrity sha512-gRO+jk2ljxZlIn20QRskIvpLCMtzuLl5T0BY6L9uvPYD17uUrxlxWkvYCiVqED2q2q7CVtY52Uex4WcYo2FEXw== + dependencies: + node-addon-api "^8.2.1" + node-gyp-build "^4.8.2" + trim-lines@^3.0.0: version "3.0.1" resolved "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz" @@ -11452,6 +11655,11 @@ web-tree-sitter@=0.24.3: resolved "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.24.3.tgz" integrity sha512-uR9YNewr1S2EzPKE+y39nAwaTyobBaZRG/IsfkB/OT4v0lXtNj5WjtHKgn2h7eOYUWIZh5rK9Px7tI6S9CRKdA== +web-tree-sitter@=0.24.5: + version "0.24.5" + resolved "https://registry.yarnpkg.com/web-tree-sitter/-/web-tree-sitter-0.24.5.tgz#16cea449da63012f23ca7b83bd32817dd0520400" + integrity sha512-+J/2VSHN8J47gQUAvF8KDadrfz6uFYVjxoxbKWDoXVsH2u7yLdarCnIURnrMA6uSRkgX3SdmqM5BOoQjPdSh5w== + webpack-bundle-analyzer@^4.9.0: version "4.10.2" resolved "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz" diff --git a/null_byte.csv b/null_byte.csv deleted file mode 100644 index 55132aaa6398b76cf42aa1473f9959dd09b08b03..0000000000000000000000000000000000000000 Binary files a/null_byte.csv and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index a4be076bbd3e36c40da4fd98c0ce2abead6cb9f1..e3fee33593183166e0631e6ae07537e33f33fc52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ requires = ["setuptools>=40.9.0", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "apache-superset" +name = "apache_superset" description = "A modern, enterprise-ready business intelligence web application" readme = "README.md" dynamic = ["version", "scripts", "entry-points"] @@ -44,7 +44,7 @@ dependencies = [ "cryptography>=42.0.4, <45.0.0", "deprecation>=2.1.0, <2.2.0", "flask>=2.2.5, <3.0.0", - "flask-appbuilder>=4.6.0, <5.0.0", + "flask-appbuilder>=4.6.1, <5.0.0", "flask-caching>=2.1.0, <3", "flask-compress>=1.13, <2.0", "flask-talisman>=1.0.0, <2.0", diff --git a/requirements/base.in b/requirements/base.in index 56085768b8f5a074de55815162d04f64abf0fbdd..f23e6ad1cad0fa359eb68a20cc59512f5ff17457 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -28,9 +28,8 @@ async_timeout>=4.0.0,<5.0.0 # a bit of attention to bump. apispec>=6.0.0,<6.7.0 -# 1.4.0 appears to use much more memory, where the python test suite runs out of memory -# causing CI to fail. 1.3.0 is the last version that works. -# This is probably related to the changes around PickleType +# 1.4.1 appears to use much more memory, where the python test suite runs out of memory +# causing CI to fail. 1.4.0 is the last version that works. # 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.0 +marshmallow-sqlalchemy>=1.3.0,<1.4.1 diff --git a/requirements/base.txt b/requirements/base.txt index 80d5f7f34868b99b91f899d8bc11c9801deb0b04..24a8442a627c86052609e03a686e3213fce97126 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -66,7 +66,7 @@ click==8.1.8 # flask-appbuilder click-didyoumean==0.3.1 # via celery -click-option-group==0.5.6 +click-option-group==0.5.7 # via apache-superset (pyproject.toml) click-plugins==1.1.1 # via celery @@ -111,7 +111,7 @@ flask==2.3.3 # flask-session # flask-sqlalchemy # flask-wtf -flask-appbuilder==4.6.0 +flask-appbuilder==4.6.1 # via apache-superset (pyproject.toml) flask-babel==2.0.0 # via flask-appbuilder @@ -151,7 +151,6 @@ greenlet==3.1.1 # via # apache-superset (pyproject.toml) # shillelagh - # sqlalchemy gunicorn==23.0.0 # via apache-superset (pyproject.toml) h11==0.14.0 @@ -160,7 +159,7 @@ hashids==1.3.1 # via apache-superset (pyproject.toml) holidays==0.25 # via apache-superset (pyproject.toml) -humanize==4.12.1 +humanize==4.12.2 # via apache-superset (pyproject.toml) idna==3.10 # via @@ -209,7 +208,7 @@ marshmallow==3.26.1 # via # flask-appbuilder # marshmallow-sqlalchemy -marshmallow-sqlalchemy==1.3.0 +marshmallow-sqlalchemy==1.4.0 # via # -r requirements/base.in # flask-appbuilder @@ -257,7 +256,7 @@ parsedatetime==2.6 # via apache-superset (pyproject.toml) pgsanity==0.2.9 # via apache-superset (pyproject.toml) -platformdirs==4.3.6 +platformdirs==4.3.7 # via requests-cache ply==3.11 # via jsonpath-ng @@ -288,7 +287,7 @@ pynacl==1.5.0 # via paramiko pyopenssl==25.0.0 # via shillelagh -pyparsing==3.2.1 +pyparsing==3.2.2 # via apache-superset (pyproject.toml) pysocks==1.7.1 # via urllib3 diff --git a/setup.py b/setup.py index df2f387fa26dcd216689895038ceedb3debc83fb..dd4cd3c33621ab9d58c681d83db71b8dd895c91d 100644 --- a/setup.py +++ b/setup.py @@ -52,6 +52,7 @@ with open(VERSION_INFO_FILE, "w") as version_file: version_string = version_string.replace("-dev", ".dev0") setup( + name="apache_superset", version=version_string, packages=find_packages(), include_package_data=True, diff --git a/superset-embedded-sdk/src/index.ts b/superset-embedded-sdk/src/index.ts index 7bcf5dcd2e91d2b9c0a75da72adbc6a7c638d56b..f0ae1cab42e9851620316bbe0ead55bc58da5a89 100644 --- a/superset-embedded-sdk/src/index.ts +++ b/superset-embedded-sdk/src/index.ts @@ -19,7 +19,7 @@ import { DASHBOARD_UI_FILTER_CONFIG_URL_PARAM_KEY, - IFRAME_COMMS_MESSAGE_TYPE + IFRAME_COMMS_MESSAGE_TYPE, } from './const'; // We can swap this out for the actual switchboard package once it gets published @@ -34,50 +34,62 @@ import { getGuestTokenRefreshTiming } from './guestTokenRefresh'; export type GuestTokenFetchFn = () => Promise<string>; export type UiConfigType = { - hideTitle?: boolean - hideTab?: boolean - hideChartControls?: boolean + hideTitle?: boolean; + hideTab?: boolean; + hideChartControls?: boolean; + emitDataMasks?: boolean; filters?: { - [key: string]: boolean | undefined - visible?: boolean - expanded?: boolean - } + [key: string]: boolean | undefined; + visible?: boolean; + expanded?: boolean; + }; urlParams?: { - [key: string]: any - } -} + [key: string]: any; + }; +}; export type EmbedDashboardParams = { /** The id provided by the embed configuration UI in Superset */ - id: string + id: string; /** The domain where Superset can be located, with protocol, such as: https://superset.example.com */ - supersetDomain: string + supersetDomain: string; /** The html element within which to mount the iframe */ - mountPoint: HTMLElement + mountPoint: HTMLElement; /** A function to fetch a guest token from the Host App's backend server */ - fetchGuestToken: GuestTokenFetchFn + fetchGuestToken: GuestTokenFetchFn; /** The dashboard UI config: hideTitle, hideTab, hideChartControls, filters.visible, filters.expanded **/ - dashboardUiConfig?: UiConfigType + dashboardUiConfig?: UiConfigType; /** Are we in debug mode? */ - debug?: boolean + debug?: boolean; /** The iframe title attribute */ - iframeTitle?: string + iframeTitle?: string; /** additional iframe sandbox attributes ex (allow-top-navigation, allow-popups-to-escape-sandbox) **/ - iframeSandboxExtras?: string[] + iframeSandboxExtras?: string[]; /** force a specific refererPolicy to be used in the iframe request **/ - referrerPolicy?: ReferrerPolicy -} + referrerPolicy?: ReferrerPolicy; +}; export type Size = { - width: number, height: number -} + width: number; + height: number; +}; +export type ObserveDataMaskCallbackFn = ( + dataMask: Record<string, any> & { + crossFiltersChanged: boolean; + nativeFiltersChanged: boolean; + }, +) => void; export type EmbeddedDashboard = { - getScrollSize: () => Promise<Size> - unmount: () => void - getDashboardPermalink: (anchor: string) => Promise<string> - getActiveTabs: () => Promise<string[]> -} + getScrollSize: () => Promise<Size>; + unmount: () => void; + getDashboardPermalink: (anchor: string) => Promise<string>; + getActiveTabs: () => Promise<string[]>; + observeDataMask: ( + callbackFn: ObserveDataMaskCallbackFn, + ) => void; + getDataMask: () => Record<string, any>; +}; /** * Embeds a Superset dashboard into the page using an iframe. @@ -89,7 +101,7 @@ export async function embedDashboard({ fetchGuestToken, dashboardUiConfig, debug = false, - iframeTitle = "Embedded Dashboard", + iframeTitle = 'Embedded Dashboard', iframeSandboxExtras = [], referrerPolicy, }: EmbedDashboardParams): Promise<EmbeddedDashboard> { @@ -101,52 +113,67 @@ export async function embedDashboard({ log('embedding'); - if (supersetDomain.endsWith("/")) { + if (supersetDomain.endsWith('/')) { supersetDomain = supersetDomain.slice(0, -1); } function calculateConfig() { - let configNumber = 0 - if(dashboardUiConfig) { - if(dashboardUiConfig.hideTitle) { - configNumber += 1 + let configNumber = 0; + if (dashboardUiConfig) { + if (dashboardUiConfig.hideTitle) { + configNumber += 1; } - if(dashboardUiConfig.hideTab) { - configNumber += 2 + if (dashboardUiConfig.hideTab) { + configNumber += 2; } - if(dashboardUiConfig.hideChartControls) { - configNumber += 8 + if (dashboardUiConfig.hideChartControls) { + configNumber += 8; + } + if (dashboardUiConfig.emitDataMasks) { + configNumber += 16; } } - return configNumber + return configNumber; } async function mountIframe(): Promise<Switchboard> { return new Promise(resolve => { const iframe = document.createElement('iframe'); - const dashboardConfigUrlParams = dashboardUiConfig ? {uiConfig: `${calculateConfig()}`} : undefined; - const filterConfig = dashboardUiConfig?.filters || {} - const filterConfigKeys = Object.keys(filterConfig) - const filterConfigUrlParams = Object.fromEntries(filterConfigKeys.map( - key => [DASHBOARD_UI_FILTER_CONFIG_URL_PARAM_KEY[key], filterConfig[key]])) + const dashboardConfigUrlParams = dashboardUiConfig + ? { uiConfig: `${calculateConfig()}` } + : undefined; + const filterConfig = dashboardUiConfig?.filters || {}; + const filterConfigKeys = Object.keys(filterConfig); + const filterConfigUrlParams = Object.fromEntries( + filterConfigKeys.map(key => [ + DASHBOARD_UI_FILTER_CONFIG_URL_PARAM_KEY[key], + filterConfig[key], + ]), + ); // Allow url query parameters from dashboardUiConfig.urlParams to override the ones from filterConfig - const urlParams = {...dashboardConfigUrlParams, ...filterConfigUrlParams, ...dashboardUiConfig?.urlParams} - const urlParamsString = Object.keys(urlParams).length ? '?' + new URLSearchParams(urlParams).toString() : '' + const urlParams = { + ...dashboardConfigUrlParams, + ...filterConfigUrlParams, + ...dashboardUiConfig?.urlParams, + }; + const urlParamsString = Object.keys(urlParams).length + ? '?' + new URLSearchParams(urlParams).toString() + : ''; // set up the iframe's sandbox configuration - iframe.sandbox.add("allow-same-origin"); // needed for postMessage to work - iframe.sandbox.add("allow-scripts"); // obviously the iframe needs scripts - iframe.sandbox.add("allow-presentation"); // for fullscreen charts - iframe.sandbox.add("allow-downloads"); // for downloading charts as image - iframe.sandbox.add("allow-forms"); // for forms to submit - iframe.sandbox.add("allow-popups"); // for exporting charts as csv + iframe.sandbox.add('allow-same-origin'); // needed for postMessage to work + iframe.sandbox.add('allow-scripts'); // obviously the iframe needs scripts + iframe.sandbox.add('allow-presentation'); // for fullscreen charts + iframe.sandbox.add('allow-downloads'); // for downloading charts as image + iframe.sandbox.add('allow-forms'); // for forms to submit + iframe.sandbox.add('allow-popups'); // for exporting charts as csv // additional sandbox props iframeSandboxExtras.forEach((key: string) => { iframe.sandbox.add(key); }); // force a specific refererPolicy to be used in the iframe request - if(referrerPolicy) { + if (referrerPolicy) { iframe.referrerPolicy = referrerPolicy; } @@ -162,20 +189,26 @@ export async function embedDashboard({ // See https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage // we know the content window isn't null because we are in the load event handler. iframe.contentWindow!.postMessage( - { type: IFRAME_COMMS_MESSAGE_TYPE, handshake: "port transfer" }, + { type: IFRAME_COMMS_MESSAGE_TYPE, handshake: 'port transfer' }, supersetDomain, [theirPort], - ) + ); log('sent message channel to the iframe'); // return our port from the promise - resolve(new Switchboard({ port: ourPort, name: 'superset-embedded-sdk', debug })); + resolve( + new Switchboard({ + port: ourPort, + name: 'superset-embedded-sdk', + debug, + }), + ); }); iframe.src = `${supersetDomain}/embedded/${id}${urlParamsString}`; iframe.title = iframeTitle; //@ts-ignore mountPoint.replaceChildren(iframe); - log('placed the iframe') + log('placed the iframe'); }); } @@ -204,12 +237,21 @@ export async function embedDashboard({ const getScrollSize = () => ourPort.get<Size>('getScrollSize'); const getDashboardPermalink = (anchor: string) => ourPort.get<string>('getDashboardPermalink', { anchor }); - const getActiveTabs = () => ourPort.get<string[]>('getActiveTabs') + const getActiveTabs = () => ourPort.get<string[]>('getActiveTabs'); + const getDataMask = () => ourPort.get<Record<string, any>>('getDataMask'); + const observeDataMask = ( + callbackFn: ObserveDataMaskCallbackFn, + ) => { + ourPort.start(); + ourPort.defineMethod('observeDataMask', callbackFn); + }; return { getScrollSize, unmount, getDashboardPermalink, getActiveTabs, + observeDataMask, + getDataMask, }; } diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/drillby.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/drillby.test.ts index 2586fbd438148158101d0b01bca0cd512aac287e..2f8254310ca0a40e650cfc235dea426f83dde5c2 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard/drillby.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/drillby.test.ts @@ -510,29 +510,29 @@ describe('Drill by modal', () => { it('Line chart', () => { testEchart('echarts_timeseries_line', 'Line Chart', [ - [70, 93], - [70, 93], + [85, 93], + [85, 93], ]); }); it('Area Chart', () => { testEchart('echarts_area', 'Area Chart', [ - [70, 93], - [70, 93], + [85, 93], + [85, 93], ]); }); it('Scatter Chart', () => { testEchart('echarts_timeseries_scatter', 'Scatter Chart', [ - [70, 93], - [70, 93], + [85, 93], + [85, 93], ]); }); it('Bar Chart', () => { testEchart('echarts_timeseries_bar', 'Bar Chart', [ - [70, 94], - [362, 68], + [85, 94], + [490, 68], ]); }); @@ -565,22 +565,22 @@ describe('Drill by modal', () => { it('Generic Chart', () => { testEchart('echarts_timeseries', 'Generic Chart', [ - [70, 93], - [70, 93], + [85, 93], + [85, 93], ]); }); it('Smooth Line Chart', () => { testEchart('echarts_timeseries_smooth', 'Smooth Line Chart', [ - [70, 93], - [70, 93], + [85, 93], + [85, 93], ]); }); it('Step Line Chart', () => { testEchart('echarts_timeseries_step', 'Step Line Chart', [ - [70, 93], - [70, 93], + [85, 93], + [85, 93], ]); }); @@ -616,8 +616,8 @@ describe('Drill by modal', () => { cy.get('[data-test-viz-type="mixed_timeseries"] canvas').then($canvas => { // click 'boy' cy.wrap($canvas).scrollIntoView(); - cy.wrap($canvas).trigger('mouseover', 70, 93); - cy.wrap($canvas).rightclick(70, 93); + cy.wrap($canvas).trigger('mouseover', 85, 93); + cy.wrap($canvas).rightclick(85, 93); drillBy('name').then(intercepted => { const { queries } = intercepted.request.body; @@ -650,8 +650,8 @@ describe('Drill by modal', () => { cy.get(`[data-test="drill-by-chart"] canvas`).then($canvas => { // click second query cy.wrap($canvas).scrollIntoView(); - cy.wrap($canvas).trigger('mouseover', 246, 114); - cy.wrap($canvas).rightclick(246, 114); + cy.wrap($canvas).trigger('mouseover', 261, 114); + cy.wrap($canvas).rightclick(261, 114); drillBy('ds').then(intercepted => { const { queries } = intercepted.request.body; diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/drilltodetail.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/drilltodetail.test.ts index 1063689edce05b6dd9f8a39ff256968d9ec541d3..eda6e56c452e698c9f06e78427e74c4d7dd024b8 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard/drilltodetail.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/drilltodetail.test.ts @@ -95,24 +95,24 @@ function testTimeChart(vizType: string) { cy.get(`[data-test-viz-type='${vizType}'] canvas`).then($canvas => { cy.wrap($canvas).scrollIntoView(); - cy.wrap($canvas).trigger('mousemove', 70, 93); - cy.wrap($canvas).rightclick(70, 93); + cy.wrap($canvas).trigger('mousemove', 85, 93); + cy.wrap($canvas).rightclick(85, 93); drillToDetailBy('Drill to detail by 1965'); cy.getBySel('filter-val').should('contain', '1965'); closeModal(); cy.wrap($canvas).scrollIntoView(); - cy.wrap($canvas).trigger('mousemove', 70, 93); - cy.wrap($canvas).rightclick(70, 93); + cy.wrap($canvas).trigger('mousemove', 85, 93); + cy.wrap($canvas).rightclick(85, 93); drillToDetailBy('Drill to detail by boy'); cy.getBySel('filter-val').should('contain', 'boy'); closeModal(); cy.wrap($canvas).scrollIntoView(); - cy.wrap($canvas).trigger('mousemove', 70, 93); - cy.wrap($canvas).rightclick(70, 93); + cy.wrap($canvas).trigger('mousemove', 85, 93); + cy.wrap($canvas).rightclick(85, 93); drillToDetailBy('Drill to detail by all'); cy.getBySel('filter-val').first().should('contain', '1965'); @@ -442,7 +442,7 @@ describe('Drill to detail modal', () => { cy.get("[data-test-viz-type='box_plot'] canvas").then($canvas => { const canvasWidth = $canvas.width() || 0; const canvasHeight = $canvas.height() || 0; - const canvasCenterX = canvasWidth / 3; + const canvasCenterX = canvasWidth / 3 + 15; const canvasCenterY = (canvasHeight * 5) / 6; cy.wrap($canvas).scrollIntoView(); diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index ca6203efbbddc7748ce5e42fbf3dcf114059fc09..1f9c50d9add7c3efb9912c05895ecb01a91f1a73 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -152,7 +152,7 @@ "devDependencies": { "@applitools/eyes-storybook": "^3.50.9", "@babel/cli": "^7.22.6", - "@babel/compat-data": "^7.22.6", + "@babel/compat-data": "^7.26.8", "@babel/core": "^7.26.0", "@babel/eslint-parser": "^7.25.9", "@babel/node": "^7.22.6", @@ -234,7 +234,7 @@ "copy-webpack-plugin": "^12.0.2", "cross-env": "^7.0.3", "css-loader": "^7.1.2", - "css-minimizer-webpack-plugin": "^7.0.0", + "css-minimizer-webpack-plugin": "^7.0.2", "enzyme": "^3.11.0", "enzyme-matchers": "^7.1.2", "eslint": "^8.56.0", @@ -1160,9 +1160,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.5.tgz", - "integrity": "sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", + "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", "dev": true, "license": "MIT", "engines": { @@ -3253,9 +3253,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", - "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", + "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -18992,16 +18992,16 @@ } }, "node_modules/css-minimizer-webpack-plugin": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-7.0.0.tgz", - "integrity": "sha512-niy66jxsQHqO+EYbhPuIhqRQ1mNcNVUHrMnkzzir9kFOERJUaQDDRhh7dKDz33kBpkWMF9M8Vx0QlDbc5AHOsw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-7.0.2.tgz", + "integrity": "sha512-nBRWZtI77PBZQgcXMNqiIXVshiQOVLGSf2qX/WZfG8IQfMbeHUMXaBWQmiiSTmPJUflQxHjZjzAmuyO7tpL2Jg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", - "cssnano": "^7.0.1", + "cssnano": "^7.0.4", "jest-worker": "^29.7.0", - "postcss": "^8.4.38", + "postcss": "^8.4.40", "schema-utils": "^4.2.0", "serialize-javascript": "^6.0.2" }, @@ -20659,6 +20659,16 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", + "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -30346,30 +30356,23 @@ } }, "node_modules/jspdf": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz", - "integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.1.tgz", + "integrity": "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.2", + "@babel/runtime": "^7.26.7", "atob": "^2.1.2", "btoa": "^1.2.1", "fflate": "^0.8.1" }, "optionalDependencies": { - "canvg": "^3.0.6", + "canvg": "^3.0.11", "core-js": "^3.6.0", - "dompurify": "^2.5.4", + "dompurify": "^3.2.4", "html2canvas": "^1.0.0-rc.5" } }, - "node_modules/jspdf/node_modules/dompurify": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz", - "integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optional": true - }, "node_modules/jsprim": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 3654d48629846d1900b033fde5bf6517ac02f32d..e46b828fc1574e0c1858b57877237a68065ef401 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -219,7 +219,7 @@ "devDependencies": { "@applitools/eyes-storybook": "^3.50.9", "@babel/cli": "^7.22.6", - "@babel/compat-data": "^7.22.6", + "@babel/compat-data": "^7.26.8", "@babel/core": "^7.26.0", "@babel/eslint-parser": "^7.25.9", "@babel/node": "^7.22.6", @@ -301,7 +301,7 @@ "copy-webpack-plugin": "^12.0.2", "cross-env": "^7.0.3", "css-loader": "^7.1.2", - "css-minimizer-webpack-plugin": "^7.0.0", + "css-minimizer-webpack-plugin": "^7.0.2", "enzyme": "^3.11.0", "enzyme-matchers": "^7.1.2", "eslint": "^8.56.0", @@ -373,7 +373,8 @@ "core-js": "^3.38.1", "d3-color": "^3.1.0", "puppeteer": "^22.4.1", - "underscore": "^1.13.7" + "underscore": "^1.13.7", + "jspdf": "^3.0.1" }, "readme": "ERROR: No README data found!", "scarfSettings": { diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/aggregateOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/aggregateOperator.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa3c518ad926bbda777b1d79f5d70866a42354f7 --- /dev/null +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/aggregateOperator.ts @@ -0,0 +1,58 @@ +/** + * 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 limitationsxw + * under the License. + */ +import { + getMetricLabel, + ensureIsArray, + PostProcessingAggregation, + QueryFormData, + Aggregates, +} from '@superset-ui/core'; +import { PostProcessingFactory } from './types'; + +export const aggregationOperator: PostProcessingFactory< + PostProcessingAggregation +> = (formData: QueryFormData, queryObject) => { + const { aggregation = 'LAST_VALUE' } = formData; + + if (aggregation === 'LAST_VALUE') { + return undefined; + } + + const metrics = ensureIsArray(queryObject.metrics); + if (metrics.length === 0) { + return undefined; + } + + const aggregates: Aggregates = {}; + metrics.forEach(metric => { + const metricLabel = getMetricLabel(metric); + aggregates[metricLabel] = { + operator: aggregation, + column: metricLabel, + }; + }); + + return { + operation: 'aggregate', + options: { + groupby: [], + aggregates, + }, + }; +}; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/index.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/index.ts index cac7088a775a74d9f8c3fbb5f4574b73750cede4..0f6a01ee1276a73e3a4073927c396bfeed95d018 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/index.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/index.ts @@ -21,6 +21,7 @@ export { rollingWindowOperator } from './rollingWindowOperator'; export { timeCompareOperator } from './timeCompareOperator'; export { timeComparePivotOperator } from './timeComparePivotOperator'; export { sortOperator } from './sortOperator'; +export { aggregationOperator } from './aggregateOperator'; export { histogramOperator } from './histogramOperator'; export { pivotOperator } from './pivotOperator'; export { resampleOperator } from './resampleOperator'; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/sections/chartTitle.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/sections/chartTitle.tsx index 6c8d08364cc28798e9699b9e2cb3ada141e92f90..43ad46ba7f71d69d2a3a530fc5fd06e2863924d9 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/sections/chartTitle.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/sections/chartTitle.tsx @@ -84,7 +84,7 @@ export const titleControls: ControlPanelSectionConfig = { clearable: true, label: t('Y Axis Title Margin'), renderTrigger: true, - default: TITLE_MARGIN_OPTIONS[0], + default: TITLE_MARGIN_OPTIONS[1], choices: formatSelectOptions(TITLE_MARGIN_OPTIONS), description: t('Changing this control takes effect instantly'), }, diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx index d25273c08e9be554aed093e865158dbc41e23779..bdd6d1b82cc78822416c962d15e59e2810316d93 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx @@ -61,6 +61,32 @@ const xAxisSortVisibility = ({ controls }: { controls: ControlStateMapping }) => ensureIsArray(controls?.groupby?.value).length === 0 && ensureIsArray(controls?.metrics?.value).length === 1; +// TODO: Expand this aggregation options list to include all backend-supported aggregations. +// TODO: Migrate existing chart types (Pivot Table, etc.) to use this shared control. +export const aggregationControl = { + name: 'aggregation', + config: { + type: 'SelectControl', + label: t('Aggregation Method'), + default: 'LAST_VALUE', + clearable: false, + renderTrigger: false, + choices: [ + ['LAST_VALUE', t('Last Value')], + ['sum', t('Total (Sum)')], + ['mean', t('Average (Mean)')], + ['min', t('Minimum')], + ['max', t('Maximum')], + ['median', t('Median')], + ], + description: t('Select an aggregation method to apply to the metric.'), + provideFormDataToProps: true, + mapStateToProps: ({ form_data }: ControlPanelState) => ({ + value: form_data.aggregation || 'LAST_VALUE', + }), + }, +}; + const xAxisMultiSortVisibility = ({ controls, }: { diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.ts b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.ts index 8ff85439bfa3b4b00dea5492448afe191532ee85..0deb6b398621d2ba2bed3e4a5749201ec3e30637 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.ts @@ -19,6 +19,7 @@ export { default as sharedControls } from './sharedControls'; // React control components export { default as sharedControlComponents } from './components'; +export { aggregationControl } from './customControls'; export * from './components'; export * from './customControls'; export * from './mixins'; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts b/superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts index 8295c60d9cd841d42a09b3395e493ea07083641c..3ff8fb9c1e200b970c70728a8e51b957f9793197 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts @@ -78,6 +78,7 @@ export const D3_TIME_FORMAT_OPTIONS: [string, string][] = [ [SMART_DATE_ID, t('Adaptive formatting')], ['%d/%m/%Y', '%d/%m/%Y | 14/01/2019'], ['%m/%d/%Y', '%m/%d/%Y | 01/14/2019'], + ['%d.%m.%Y', '%d.%m.%Y | 14.01.2019'], ['%Y-%m-%d', '%Y-%m-%d | 2019-01-14'], ['%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M:%S | 2019-01-14 01:32:10'], ['%d-%m-%Y %H:%M:%S', '%d-%m-%Y %H:%M:%S | 14-01-2019 01:32:10'], diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/operators/aggregateOperator.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/operators/aggregateOperator.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..52e3e45407817cc3ab2d44b1e594ddd2dd82ee10 --- /dev/null +++ b/superset-frontend/packages/superset-ui-chart-controls/test/operators/aggregateOperator.test.ts @@ -0,0 +1,121 @@ +/** + * 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 { QueryObject, SqlaFormData, VizType } from '@superset-ui/core'; +import { aggregationOperator } from '@superset-ui/chart-controls'; + +describe('aggregationOperator', () => { + const formData: SqlaFormData = { + metrics: [ + 'count(*)', + { label: 'sum(val)', expressionType: 'SQL', sqlExpression: 'sum(val)' }, + ], + time_range: '2015 : 2016', + granularity: 'month', + datasource: 'foo', + viz_type: VizType.Table, + }; + + const queryObject: QueryObject = { + metrics: [ + 'count(*)', + { label: 'sum(val)', expressionType: 'SQL', sqlExpression: 'sum(val)' }, + ], + time_range: '2015 : 2016', + granularity: 'month', + }; + + test('should return undefined for LAST_VALUE aggregation', () => { + const formDataWithLastValue = { + ...formData, + aggregation: 'LAST_VALUE', + }; + + expect( + aggregationOperator(formDataWithLastValue, queryObject), + ).toBeUndefined(); + }); + + test('should return undefined when metrics is empty', () => { + const queryObjectWithoutMetrics = { + ...queryObject, + metrics: [], + }; + + const formDataWithSum = { + ...formData, + aggregation: 'sum', + }; + + expect( + aggregationOperator(formDataWithSum, queryObjectWithoutMetrics), + ).toBeUndefined(); + }); + + test('should apply sum aggregation to all metrics', () => { + const formDataWithSum = { + ...formData, + aggregation: 'sum', + }; + + expect(aggregationOperator(formDataWithSum, queryObject)).toEqual({ + operation: 'aggregate', + options: { + groupby: [], + aggregates: { + 'count(*)': { + operator: 'sum', + column: 'count(*)', + }, + 'sum(val)': { + operator: 'sum', + column: 'sum(val)', + }, + }, + }, + }); + }); + + test('should apply mean aggregation to all metrics', () => { + const formDataWithMean = { + ...formData, + aggregation: 'mean', + }; + + expect(aggregationOperator(formDataWithMean, queryObject)).toEqual({ + operation: 'aggregate', + options: { + groupby: [], + aggregates: { + 'count(*)': { + operator: 'mean', + column: 'count(*)', + }, + 'sum(val)': { + operator: 'mean', + column: 'sum(val)', + }, + }, + }, + }); + }); + + test('should use default aggregation when not specified', () => { + expect(aggregationOperator(formData, queryObject)).toBeUndefined(); + }); +}); diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/operators/timeCompareOperator.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/operators/timeCompareOperator.test.ts index c7861af2ee53d9d9a6c042648d3d66815ea2f451..437d3064bf5865d2c6b66ff04c31e1c7628f863f 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/operators/timeCompareOperator.test.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/test/operators/timeCompareOperator.test.ts @@ -54,7 +54,7 @@ const queryObject: QueryObject = { }, }, { - operation: 'aggregation', + operation: 'aggregate', options: { groupby: ['col1'], aggregates: {}, diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/PostProcessing.ts b/superset-frontend/packages/superset-ui-core/src/query/types/PostProcessing.ts index a70d0111f2ec61e6b7afb43583dc85e8199d31a1..79bcabdaff12c8cf2a9f839c2dc658fba7522433 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/PostProcessing.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/PostProcessing.ts @@ -67,7 +67,7 @@ export interface Aggregates { export type DefaultPostProcessing = undefined; interface _PostProcessingAggregation { - operation: 'aggregation'; + operation: 'aggregate'; options: { groupby: string[]; aggregates: Aggregates; @@ -271,7 +271,7 @@ export type PostProcessingRule = export function isPostProcessingAggregation( rule?: PostProcessingRule, ): rule is PostProcessingAggregation { - return rule?.operation === 'aggregation'; + return rule?.operation === 'aggregate'; } export function isPostProcessingBoxplot( diff --git a/superset-frontend/packages/superset-ui-core/test/query/types/PostProcessing.test.ts b/superset-frontend/packages/superset-ui-core/test/query/types/PostProcessing.test.ts index 05c385fb4e6af798013c6331665982591a094f2d..4e4ff949cbd9abc2a411201602b9e194e97d5fd2 100644 --- a/superset-frontend/packages/superset-ui-core/test/query/types/PostProcessing.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/query/types/PostProcessing.test.ts @@ -61,7 +61,7 @@ const AGGREGATES_OPTION: Aggregates = { }; const AGGREGATE_RULE: PostProcessingAggregation = { - operation: 'aggregation', + operation: 'aggregate', options: { groupby: ['foo'], aggregates: AGGREGATES_OPTION, diff --git a/superset-frontend/packages/superset-ui-core/test/time-comparison/getComparisonInfo.test.ts b/superset-frontend/packages/superset-ui-core/test/time-comparison/getComparisonInfo.test.ts index 9540badf3342fa7ef346225a6a4039389e28b6b8..ffaa58f48f7a152ac825d4b04b1900e332dab36d 100644 --- a/superset-frontend/packages/superset-ui-core/test/time-comparison/getComparisonInfo.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/time-comparison/getComparisonInfo.test.ts @@ -63,6 +63,11 @@ const form_data = { header_font_size: 60, subheader_font_size: 26, comparison_color_enabled: true, + column_config: { + name: { + visible: true, + }, + }, extra_form_data: {}, force: false, result_format: 'json', @@ -142,7 +147,7 @@ describe('getComparisonInfo', () => { expect(resultFormData.adhoc_filters?.[0]).toEqual(expectedFilters[0]); }); - it('If adhoc_filter is undefrined the code wont break', () => { + it('If adhoc_filter is undefined the code wont break', () => { const resultFormData = getComparisonInfo( { ...form_data, @@ -175,4 +180,21 @@ describe('getComparisonInfo', () => { expect(resultFormData.adhoc_filters?.length).toEqual(1); expect(resultFormData.adhoc_filters).toEqual(expectedFilters); }); + + it('Updates comparison display values when toggled', () => { + const resultFormData = getComparisonInfo( + { + ...form_data, + column_config: { + name: { + visible: false, + }, + }, + }, + ComparisonTimeRangeType.Year, + {}, + ); + + expect(resultFormData.column_config.name.visible).toEqual(false); + }); }); diff --git a/superset-frontend/plugins/plugin-chart-echarts/package.json b/superset-frontend/plugins/plugin-chart-echarts/package.json index 64da86afdcc8c0a6043df454c765c1b671a87848..388fc8f72227b7bb14771f557b09298f8184c813 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/package.json +++ b/superset-frontend/plugins/plugin-chart-echarts/package.json @@ -24,10 +24,10 @@ "lib" ], "dependencies": { + "@types/react-redux": "^7.1.34", "d3-array": "^1.2.0", - "lodash": "^4.17.21", "dayjs": "^1.11.13", - "@types/react-redux": "^7.1.10" + "lodash": "^4.17.21" }, "peerDependencies": { "@superset-ui/chart-controls": "*", diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/PopKPI.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/PopKPI.tsx index a4736c89c20586525639f88d4d2ec5ef534e5392..9ec2111071ba37b221d2309ae8b9c7e015a3282a 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/PopKPI.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/PopKPI.tsx @@ -200,16 +200,19 @@ export default function PopKPI(props: PopKPIProps) { symbol: '#', value: prevNumber, tooltipText: t('Data for %s', comparisonRange || 'previous range'), + columnKey: 'Previous value', }, { symbol: '△', value: valueDifference, tooltipText: t('Value difference between the time periods'), + columnKey: 'Delta', }, { symbol: '%', value: percentDifferenceFormattedString, tooltipText: t('Percentage difference between the time periods'), + columnKey: 'Percent change', }, ], [ @@ -220,6 +223,10 @@ export default function PopKPI(props: PopKPIProps) { ], ); + const visibleSymbols = SYMBOLS_WITH_VALUES.filter( + symbol => props.columnConfig?.[symbol.columnKey]?.visible !== false, + ); + const { isOverflowing, symbolContainerRef, wrapperRef } = useOverflowDetection(flexGap); @@ -244,51 +251,53 @@ export default function PopKPI(props: PopKPIProps) { )} </div> - <div - css={[ - css` - display: flex; - justify-content: space-around; - gap: ${flexGap}px; - min-width: 0; - flex-shrink: 1; - `, - isOverflowing - ? css` - flex-direction: column; - align-items: flex-start; - width: fit-content; - ` - : css` - align-items: center; - width: 100%; - `, - ]} - ref={symbolContainerRef} - > - {SYMBOLS_WITH_VALUES.map((symbol_with_value, index) => ( - <ComparisonValue - key={`comparison-symbol-${symbol_with_value.symbol}`} - subheaderFontSize={subheaderFontSize} - > - <Tooltip - id="tooltip" - placement="top" - title={symbol_with_value.tooltipText} + {visibleSymbols.length > 0 && ( + <div + css={[ + css` + display: flex; + justify-content: space-around; + gap: ${flexGap}px; + min-width: 0; + flex-shrink: 1; + `, + isOverflowing + ? css` + flex-direction: column; + align-items: flex-start; + width: fit-content; + ` + : css` + align-items: center; + width: 100%; + `, + ]} + ref={symbolContainerRef} + > + {visibleSymbols.map((symbol_with_value, index) => ( + <ComparisonValue + key={`comparison-symbol-${symbol_with_value.symbol}`} + subheaderFontSize={subheaderFontSize} > - <SymbolWrapper - backgroundColor={ - index > 0 ? backgroundColor : defaultBackgroundColor - } - textColor={index > 0 ? textColor : defaultTextColor} + <Tooltip + id="tooltip" + placement="top" + title={symbol_with_value.tooltipText} > - {symbol_with_value.symbol} - </SymbolWrapper> - {symbol_with_value.value} - </Tooltip> - </ComparisonValue> - ))} - </div> + <SymbolWrapper + backgroundColor={ + index > 0 ? backgroundColor : defaultBackgroundColor + } + textColor={index > 0 ? textColor : defaultTextColor} + > + {symbol_with_value.symbol} + </SymbolWrapper> + {symbol_with_value.value} + </Tooltip> + </ComparisonValue> + ))} + </div> + )} </NumbersContainer> </div> ); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/controlPanel.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/controlPanel.ts index ce934c43360b6194c2a89334d04fef83e0cf5dd4..bb285a70b0c9e8c1eb9f0512fdaab186a0805299 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/controlPanel.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/controlPanel.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { t } from '@superset-ui/core'; +import { t, GenericDataType } from '@superset-ui/core'; import { ControlPanelConfig, getStandardizedControls, @@ -106,6 +106,42 @@ const config: ControlPanelConfig = { }, }, ], + [ + { + name: 'column_config', + config: { + type: 'ColumnConfigControl', + label: t('Customize columns'), + description: t('Further customize how to display each column'), + width: 400, + height: 320, + renderTrigger: true, + configFormLayout: { + [GenericDataType.Numeric]: [ + { + tab: t('General'), + children: [['visible']], + }, + ], + }, + shouldMapStateToProps() { + return true; + }, + mapStateToProps(explore, _, chart) { + return { + columnsPropsObject: { + colnames: ['Previous value', 'Delta', 'Percent change'], + coltypes: [ + GenericDataType.Numeric, + GenericDataType.Numeric, + GenericDataType.Numeric, + ], + }, + }; + }, + }, + }, + ], ], }, sections.timeComparisonControls({ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts index b434fbbc58e43f727e3d22a2cfffe6a6f06b6bfc..9adf3e1fba7a7ae6d30f0acee268f148134483dd 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts @@ -89,6 +89,7 @@ export default function transformProps(chartProps: ChartProps) { comparisonColorScheme, comparisonColorEnabled, percentDifferenceFormat, + columnConfig, } = formData; const { data: dataA = [] } = queriesData[0]; const data = dataA; @@ -193,5 +194,6 @@ export default function transformProps(chartProps: ChartProps) { startDateOffset, shift: timeComparison, dashboardTimeRange: formData?.extraFormData?.time_range, + columnConfig, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/types.ts index e0ece4d8777ad9213499b536a0013d2e2ea3ee9a..8aef509088d0f4fa70c0134b94c60d741d332653 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/types.ts @@ -33,6 +33,10 @@ export interface PopKPIStylesProps { comparisonColorEnabled: boolean; } +export type TableColumnConfig = { + visible?: boolean; +}; + interface PopKPICustomizeProps { headerText: string; } @@ -66,6 +70,7 @@ export type PopKPIProps = PopKPIStylesProps & startDateOffset?: string; shift: string; dashboardTimeRange?: string; + columnConfig?: Record<string, TableColumnConfig>; }; export enum ColorSchemeEnum { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/buildQuery.ts index 7a0ba462b88b2d33a4cd13b0b10d250d73ce6cca..398125719b1e49df67d261082567c6facb1e891e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/buildQuery.ts @@ -24,6 +24,7 @@ import { QueryFormData, } from '@superset-ui/core'; import { + aggregationOperator, flattenOperator, pivotOperator, resampleOperator, @@ -47,5 +48,19 @@ 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), + ], + }, ]); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx index 83cf915c7ced055306b37d18157627cf1428e7e7..ea8f9c66f485623acae5cb8ae3f4c484e0e3677e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx @@ -18,6 +18,7 @@ */ import { SMART_DATE_ID, t } from '@superset-ui/core'; import { + aggregationControl, ControlPanelConfig, ControlSubSectionHeader, D3_FORMAT_DOCS, @@ -35,6 +36,7 @@ const config: ControlPanelConfig = { controlSetRows: [ ['x_axis'], ['time_grain_sqla'], + [aggregationControl], ['metric'], ['adhoc_filters'], ], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts index d285a551b136c948a942389c38a0994e7842dc54..53a44d9e3b0aa112003dd1d992fd081bff7474d0 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts @@ -66,6 +66,7 @@ export default function transformProps( metric = 'value', showTimestamp, showTrendLine, + aggregation, startYAxisAtZero, subheader = '', subheaderFontSize, @@ -82,6 +83,15 @@ export default function transformProps( from_dttm: fromDatetime, to_dttm: toDatetime, } = queriesData[0]; + + const aggregatedQueryData = queriesData.length > 1 ? queriesData[1] : null; + + const hasAggregatedData = + aggregatedQueryData?.data && + aggregatedQueryData.data.length > 0 && + aggregation !== 'LAST_VALUE'; + + const aggregatedData = hasAggregatedData ? aggregatedQueryData.data[0] : null; const refs: Refs = {}; const metricName = getMetricLabel(metric); const compareLag = Number(compareLag_) || 0; @@ -95,18 +105,39 @@ export default function transformProps( let percentChange = 0; let bigNumber = data.length === 0 ? null : data[0][metricName]; let timestamp = data.length === 0 ? null : data[0][xAxisLabel]; - let bigNumberFallback; - - const metricColtypeIndex = colnames.findIndex(name => name === metricName); - const metricColtype = - metricColtypeIndex > -1 ? coltypes[metricColtypeIndex] : null; + let bigNumberFallback = null; + let sortedData: [number | null, number | null][] = []; if (data.length > 0) { - const sortedData = (data as BigNumberDatum[]) - .map(d => [d[xAxisLabel], parseMetricValue(d[metricName])]) + sortedData = (data as BigNumberDatum[]) + .map( + d => + [d[xAxisLabel], parseMetricValue(d[metricName])] as [ + number | null, + number | null, + ], + ) // sort in time descending order .sort((a, b) => (a[0] !== null && b[0] !== null ? b[0] - a[0] : 0)); + } + if (hasAggregatedData && aggregatedData) { + if ( + aggregatedData[metricName] !== null && + aggregatedData[metricName] !== undefined + ) { + bigNumber = aggregatedData[metricName]; + } else { + const metricKeys = Object.keys(aggregatedData).filter( + key => + key !== xAxisLabel && + aggregatedData[key] !== null && + typeof aggregatedData[key] === 'number', + ); + bigNumber = metricKeys.length > 0 ? aggregatedData[metricKeys[0]] : null; + } + timestamp = sortedData.length > 0 ? sortedData[0][0] : null; + } else if (sortedData.length > 0) { bigNumber = sortedData[0][1]; timestamp = sortedData[0][0]; @@ -115,25 +146,28 @@ export default function transformProps( bigNumber = bigNumberFallback ? bigNumberFallback[1] : null; timestamp = bigNumberFallback ? bigNumberFallback[0] : null; } + } - if (compareLag > 0) { - const compareIndex = compareLag; - if (compareIndex < sortedData.length) { - const compareValue = sortedData[compareIndex][1]; - // compare values must both be non-nulls - if (bigNumber !== null && compareValue !== null) { - percentChange = compareValue - ? (bigNumber - compareValue) / Math.abs(compareValue) - : 0; - formattedSubheader = `${formatPercentChange( - percentChange, - )} ${compareSuffix}`; - } + if (compareLag > 0 && sortedData.length > 0) { + const compareIndex = compareLag; + if (compareIndex < sortedData.length) { + const compareValue = sortedData[compareIndex][1]; + // compare values must both be non-nulls + if (bigNumber !== null && compareValue !== null) { + percentChange = compareValue + ? (Number(bigNumber) - compareValue) / Math.abs(compareValue) + : 0; + formattedSubheader = `${formatPercentChange( + percentChange, + )} ${compareSuffix}`; } } - sortedData.reverse(); + } + + if (data.length > 0) { + const reversedData = [...sortedData].reverse(); // @ts-ignore - trendLineData = showTrendLine ? sortedData : undefined; + trendLineData = showTrendLine ? reversedData : undefined; } let className = ''; @@ -143,6 +177,10 @@ export default function transformProps( className = 'negative'; } + const metricColtypeIndex = colnames.findIndex(name => name === metricName); + const metricColtype = + metricColtypeIndex > -1 ? coltypes[metricColtypeIndex] : null; + let metricEntry: Metric | undefined; if (chartProps.datasource?.metrics) { metricEntry = chartProps.datasource.metrics.find( diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts index 1888383a5232a59a713030b54b16d4e0a443f8bc..49e51f511b38006ff249c1de32d5a09ca0621d5f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts @@ -201,7 +201,7 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) { name: bubbleXAxisTitle, nameLocation: 'middle', nameTextStyle: { - fontWight: 'bolder', + fontWeight: 'bolder', }, nameGap: convertInteger(xAxisTitleMargin), type: xAxisType, @@ -219,7 +219,7 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) { name: bubbleYAxisTitle, nameLocation: 'middle', nameTextStyle: { - fontWight: 'bolder', + fontWeight: 'bolder', }, nameGap: convertInteger(yAxisTitleMargin), min: yAxisMin, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx index ee2d9a6c5cf502ced4ed7b0b343fddf95f23a38d..55cd48736a0b46fcbd25927b68dd8b2f48bccf15 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx @@ -122,7 +122,7 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] { clearable: true, label: t('AXIS TITLE MARGIN'), renderTrigger: true, - default: sections.TITLE_MARGIN_OPTIONS[0], + default: sections.TITLE_MARGIN_OPTIONS[1], choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS), description: t('Changing this control takes effect instantly'), visibility: ({ controls }: ControlPanelsContainerProps) => diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Tree/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Tree/controlPanel.tsx index 34d65a27d315d655be1945298a4a7c4caef8f4d2..4d8b0fa978ee348420dbf3ae6435af0a9af01d48 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Tree/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Tree/controlPanel.tsx @@ -89,9 +89,9 @@ const controlPanel: ControlPanelConfig = { { name: 'metric', config: { - ...optionalEntity, - type: 'DndMetricSelect', - label: t('Metric'), + ...sharedControls.metric, + clearable: true, + validators: [], description: t('Metric for node values'), }, }, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts index ef10fb9d3ba6272506bf3dbc4e6b7547dffc4e03..157ab46f4835ad735689c271823a7a02b0bf2975 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -156,9 +156,15 @@ export function sortAndFilterSeries( case SortSeriesType.Avg: aggregator = name => ({ name, value: meanBy(rows, name) }); break; - default: - aggregator = name => ({ name, value: name.toLowerCase() }); - break; + default: { + const collator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', + }); + return seriesNames.sort((a, b) => + sortSeriesAscending ? collator.compare(a, b) : collator.compare(b, a), + ); + } } const sortedValues = seriesNames.map(aggregator); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts index 8b0bf3552585931dfcff64c5793d395e438d9a53..4ccedd1e7f215c9dc52fc84b07e3974d859ae1d8 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts @@ -186,3 +186,188 @@ describe('BigNumberWithTrendline', () => { }); }); }); + +describe('BigNumberWithTrendline - Aggregation Tests', () => { + const baseProps = { + width: 800, + height: 600, + formData: { + colorPicker: { r: 0, g: 0, b: 0, a: 1 }, + metric: 'metric', + aggregation: 'LAST_VALUE', + }, + queriesData: [ + { + data: [ + { __timestamp: 1607558400000, metric: 10 }, + { __timestamp: 1607558500000, metric: 30 }, + { __timestamp: 1607558600000, metric: 50 }, + { __timestamp: 1607558700000, metric: 60 }, + ], + colnames: ['__timestamp', 'metric'], + coltypes: ['TIMESTAMP', 'BIGINT'], + }, + ], + hooks: {}, + filterState: {}, + datasource: { + columnFormats: {}, + currencyFormats: {}, + }, + rawDatasource: {}, + rawFormData: {}, + theme: { + colors: { + grayscale: { + light5: '#fafafa', + }, + }, + }, + } as unknown as BigNumberWithTrendlineChartProps; + + const propsWithEvenData = { + ...baseProps, + queriesData: [ + { + data: [ + { __timestamp: 1607558400000, metric: 10 }, + { __timestamp: 1607558500000, metric: 20 }, + { __timestamp: 1607558600000, metric: 30 }, + { __timestamp: 1607558700000, metric: 40 }, + ], + colnames: ['__timestamp', 'metric'], + coltypes: ['TIMESTAMP', 'BIGINT'], + }, + ], + } as unknown as BigNumberWithTrendlineChartProps; + + it('should correctly calculate SUM', () => { + const props = { + ...baseProps, + formData: { ...baseProps.formData, aggregation: 'sum' }, + queriesData: [ + baseProps.queriesData[0], + { + data: [{ metric: 150 }], + colnames: ['metric'], + coltypes: ['BIGINT'], + }, + ], + } as unknown as BigNumberWithTrendlineChartProps; + + const transformed = transformProps(props); + expect(transformed.bigNumber).toStrictEqual(150); + }); + + it('should correctly calculate AVG', () => { + const props = { + ...baseProps, + formData: { ...baseProps.formData, aggregation: 'mean' }, + queriesData: [ + baseProps.queriesData[0], + { + data: [{ metric: 37.5 }], + colnames: ['metric'], + coltypes: ['BIGINT'], + }, + ], + } as unknown as BigNumberWithTrendlineChartProps; + + const transformed = transformProps(props); + expect(transformed.bigNumber).toStrictEqual(37.5); + }); + + it('should correctly calculate MIN', () => { + const props = { + ...baseProps, + formData: { ...baseProps.formData, aggregation: 'min' }, + queriesData: [ + baseProps.queriesData[0], + { + data: [{ metric: 10 }], + colnames: ['metric'], + coltypes: ['BIGINT'], + }, + ], + } as unknown as BigNumberWithTrendlineChartProps; + + const transformed = transformProps(props); + expect(transformed.bigNumber).toStrictEqual(10); + }); + + it('should correctly calculate MAX', () => { + const props = { + ...baseProps, + formData: { ...baseProps.formData, aggregation: 'max' }, + queriesData: [ + baseProps.queriesData[0], + { + data: [{ metric: 60 }], + colnames: ['metric'], + coltypes: ['BIGINT'], + }, + ], + } as unknown as BigNumberWithTrendlineChartProps; + + const transformed = transformProps(props); + expect(transformed.bigNumber).toStrictEqual(60); + }); + + it('should correctly calculate MEDIAN (odd count)', () => { + const oddCountProps = { + ...baseProps, + queriesData: [ + { + data: [ + { __timestamp: 1607558300000, metric: 10 }, + { __timestamp: 1607558400000, metric: 20 }, + { __timestamp: 1607558500000, metric: 30 }, + { __timestamp: 1607558600000, metric: 40 }, + { __timestamp: 1607558700000, metric: 50 }, + ], + colnames: ['__timestamp', 'metric'], + coltypes: ['TIMESTAMP', 'BIGINT'], + }, + ], + } as unknown as BigNumberWithTrendlineChartProps; + + const props = { + ...oddCountProps, + formData: { ...oddCountProps.formData, aggregation: 'median' }, + queriesData: [ + oddCountProps.queriesData[0], + { + data: [{ metric: 30 }], + colnames: ['metric'], + coltypes: ['BIGINT'], + }, + ], + } as unknown as BigNumberWithTrendlineChartProps; + + const transformed = transformProps(props); + expect(transformed.bigNumber).toStrictEqual(30); + }); + + it('should correctly calculate MEDIAN (even count)', () => { + const props = { + ...propsWithEvenData, + formData: { ...propsWithEvenData.formData, aggregation: 'median' }, + queriesData: [ + propsWithEvenData.queriesData[0], + { + data: [{ metric: 25 }], + colnames: ['metric'], + coltypes: ['BIGINT'], + }, + ], + } as unknown as BigNumberWithTrendlineChartProps; + + const transformed = transformProps(props); + expect(transformed.bigNumber).toStrictEqual(25); + }); + + it('should return the LAST_VALUE correctly', () => { + const transformed = transformProps(baseProps); + expect(transformed.bigNumber).toStrictEqual(10); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts index 7054f6019ad3051316328202b1d21d300c621e27..67a0bab9e605e252849c7044ea5868326ae85bfa 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts @@ -67,6 +67,39 @@ const sortData: DataRecord[] = [ { my_x_axis: null, x: 4, y: 3, z: 7 }, ]; +const sortDataWithNumbers: DataRecord[] = [ + { + my_x_axis: 'my_axis', + '9. September': 6, + 6: 1, + '11. November': 8, + 8: 2, + '10. October': 1, + 10: 4, + '3. March': 2, + '8. August': 6, + 2: 1, + 12: 3, + 9: 1, + '1. January': 1, + '4. April': 12, + '2. February': 9, + 5: 4, + 3: 1, + 11: 2, + '12. December': 4, + 1: 7, + '6. June': 1, + 4: 5, + 7: 2, + c: 0, + '7. July': 2, + d: 0, + '5. May': 4, + a: 1, + }, +]; + const totalStackedValues = [3, 15, 14]; test('sortRows by name ascending', () => { @@ -288,6 +321,84 @@ test('sortAndFilterSeries by name descending', () => { sortAndFilterSeries(sortData, 'my_x_axis', [], SortSeriesType.Name, false), ).toEqual(['z', 'y', 'x']); }); +test('sortAndFilterSeries by name with numbers asc', () => { + expect( + sortAndFilterSeries( + sortDataWithNumbers, + 'my_x_axis', + [], + SortSeriesType.Name, + true, + ), + ).toEqual([ + '1', + '1. January', + '2', + '2. February', + '3', + '3. March', + '4', + '4. April', + '5', + '5. May', + '6', + '6. June', + '7', + '7. July', + '8', + '8. August', + '9', + '9. September', + '10', + '10. October', + '11', + '11. November', + '12', + '12. December', + 'a', + 'c', + 'd', + ]); +}); +test('sortAndFilterSeries by name with numbers desc', () => { + expect( + sortAndFilterSeries( + sortDataWithNumbers, + 'my_x_axis', + [], + SortSeriesType.Name, + false, + ), + ).toEqual([ + 'd', + 'c', + 'a', + '12. December', + '12', + '11. November', + '11', + '10. October', + '10', + '9. September', + '9', + '8. August', + '8', + '7. July', + '7', + '6. June', + '6', + '5. May', + '5', + '4. April', + '4', + '3. March', + '3', + '2. February', + '2', + '1. January', + '1', + ]); +}); describe('extractSeries', () => { it('should generate a valid ECharts timeseries series object', () => { diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx index aef8b8b321262e48f47f8b3fb668d8d7ebb98f1a..a17bac64aa78a73c6b891912f9b843233d4771a0 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx @@ -51,7 +51,6 @@ const Styles = styled.div<PivotTableStylesProps>` width: ${ typeof width === 'string' ? parseInt(width, 10) : width - margin * 2 }px; - white-space: nowrap; `} `; diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx index 77905ea562edb125401b1b3d8d76cfd4540891a1..95121a317d48387a171bfc68d80e5977cada5d60 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx @@ -605,6 +605,8 @@ export default function TableChart<D extends DataRecord = DataRecord>( // Calculate the number of placeholder columns needed before the current header const startPosition = value[0]; const colSpan = value.length; + // Retrieve the originalLabel from the first column in this group + const originalLabel = columnsMeta[value[0]]?.originalLabel || key; // Add placeholder <th> for columns before this header for (let i = currentColumnIndex; i < startPosition; i += 1) { @@ -620,7 +622,7 @@ export default function TableChart<D extends DataRecord = DataRecord>( // Add the current header <th> headers.push( <th key={`header-${key}`} colSpan={colSpan} style={{ borderBottom: 0 }}> - {key} + {originalLabel} <span css={css` float: right; @@ -975,7 +977,7 @@ export default function TableChart<D extends DataRecord = DataRecord>( ), Footer: totals ? ( i === 0 ? ( - <th> + <th key={`footer-summary-${i}`}> <div css={css` display: flex; @@ -997,7 +999,7 @@ export default function TableChart<D extends DataRecord = DataRecord>( </div> </th> ) : ( - <td style={sharedStyle}> + <td key={`footer-total-${i}`} style={sharedStyle}> <strong>{formatColumnValue(column, totals[key])[1]}</strong> </td> ) diff --git a/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts b/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts index 7068ab11930478def0bc2f68b2abeac7054024d2..5b9cb684ed4a4474737834b73e26dc52a5caa536 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts @@ -198,11 +198,6 @@ const buildQuery: BuildQuery<TableChartFormData> = ( (ownState.currentPage ?? 0) * (ownState.pageSize ?? 0); } - if (!temporalColumn) { - // This query is not using temporal column, so it doesn't need time grain - extras.time_grain_sqla = undefined; - } - let queryObject = { ...baseQueryObject, columns, diff --git a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx index 933ee6a0c1d0af1781d2a025c069b3683967c9b3..ef3a8e700c247649a0f468b9c9eb186c12ace0b1 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx @@ -486,8 +486,9 @@ const config: ControlPanelConfig = { return true; }, mapStateToProps(explore, _, chart) { - const timeComparisonStatus = - !!explore?.controls?.time_compare?.value; + const timeComparisonStatus = !isEmpty( + explore?.controls?.time_compare?.value, + ); const { colnames: _colnames, coltypes: _coltypes } = chart?.queriesResponse?.[0] ?? {}; diff --git a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts index 48871e4ea4185b2a3ee0c6fef3111ab42f5d55cb..d62d9cb92c95b121e16020f9f1bd3f3fe3be440e 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts @@ -347,6 +347,7 @@ const processComparisonColumns = ( } = props; const savedFormat = columnFormats?.[col.key]; const savedCurrency = currencyFormats?.[col.key]; + const originalLabel = col.label; if ( (col.isMetric || col.isPercentMetric) && !col.key.includes(comparisonSuffix) && @@ -355,6 +356,7 @@ const processComparisonColumns = ( return [ { ...col, + originalLabel, label: t('Main'), key: `${t('Main')} ${col.key}`, config: getComparisonColConfig(t('Main'), col.key, columnConfig), @@ -368,6 +370,7 @@ const processComparisonColumns = ( }, { ...col, + originalLabel, label: `#`, key: `# ${col.key}`, config: getComparisonColConfig(`#`, col.key, columnConfig), @@ -381,6 +384,7 @@ const processComparisonColumns = ( }, { ...col, + originalLabel, label: `△`, key: `△ ${col.key}`, config: getComparisonColConfig(`△`, col.key, columnConfig), @@ -394,6 +398,7 @@ const processComparisonColumns = ( }, { ...col, + originalLabel, label: `%`, key: `% ${col.key}`, config: getComparisonColConfig(`%`, col.key, columnConfig), diff --git a/superset-frontend/plugins/plugin-chart-table/src/types.ts b/superset-frontend/plugins/plugin-chart-table/src/types.ts index 1ec3cbe29d724395b51f93ca377bdca5116c1828..62a666a88e7db6bff58e073457942d7d5ec6353c 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/types.ts @@ -56,6 +56,8 @@ export interface DataColumnMeta { key: string; // `label` is verbose column name used for rendering label: string; + // `originalLabel` preserves the original label when time comparison transforms the labels + originalLabel?: string; dataType: GenericDataType; formatter?: | TimeFormatter diff --git a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx index b21a657b8150e314995f514da05b9db3632a111a..b74e1ffccf4a4168d94ab366328d3d884c92ca1c 100644 --- a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx +++ b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx @@ -175,6 +175,75 @@ describe('plugin-chart-table', () => { ?.formatter?.(0.123456); expect(formattedPercentMetric).toBe('0.123'); }); + + it('should set originalLabel for comparison columns when time_compare and comparison_type are set', () => { + const transformedProps = transformProps(testData.comparison); + + // Check if comparison columns are processed + const comparisonColumns = transformedProps.columns.filter( + col => + col.label === 'Main' || + col.label === '#' || + col.label === '△' || + col.label === '%', + ); + + expect(comparisonColumns.length).toBeGreaterThan(0); + expect(comparisonColumns.some(col => col.label === 'Main')).toBe(true); + expect(comparisonColumns.some(col => col.label === '#')).toBe(true); + expect(comparisonColumns.some(col => col.label === '△')).toBe(true); + expect(comparisonColumns.some(col => col.label === '%')).toBe(true); + + // Verify originalLabel for metric_1 comparison columns + const mainMetric1 = transformedProps.columns.find( + col => col.key === 'Main metric_1', + ); + expect(mainMetric1).toBeDefined(); + expect(mainMetric1?.originalLabel).toBe('metric_1'); + + const hashMetric1 = transformedProps.columns.find( + col => col.key === '# metric_1', + ); + expect(hashMetric1).toBeDefined(); + expect(hashMetric1?.originalLabel).toBe('metric_1'); + + const deltaMetric1 = transformedProps.columns.find( + col => col.key === '△ metric_1', + ); + expect(deltaMetric1).toBeDefined(); + expect(deltaMetric1?.originalLabel).toBe('metric_1'); + + const percentMetric1 = transformedProps.columns.find( + col => col.key === '% metric_1', + ); + expect(percentMetric1).toBeDefined(); + expect(percentMetric1?.originalLabel).toBe('metric_1'); + + // Verify originalLabel for metric_2 comparison columns + const mainMetric2 = transformedProps.columns.find( + col => col.key === 'Main metric_2', + ); + expect(mainMetric2).toBeDefined(); + expect(mainMetric2?.originalLabel).toBe('metric_2'); + + const hashMetric2 = transformedProps.columns.find( + col => col.key === '# metric_2', + ); + expect(hashMetric2).toBeDefined(); + expect(hashMetric2?.originalLabel).toBe('metric_2'); + + const deltaMetric2 = transformedProps.columns.find( + col => col.key === '△ metric_2', + ); + expect(deltaMetric2).toBeDefined(); + expect(deltaMetric2?.originalLabel).toBe('metric_2'); + + const percentMetric2 = transformedProps.columns.find( + col => col.key === '% metric_2', + ); + expect(percentMetric2).toBeDefined(); + expect(percentMetric2?.originalLabel).toBe('metric_2'); + }); }); describe('TableChart', () => { @@ -400,6 +469,17 @@ describe('plugin-chart-table', () => { ); expect(getComputedStyle(screen.getByText('N/A')).background).toBe(''); }); + it('should display originalLabel in grouped headers', () => { + render( + <ThemeProvider theme={supersetTheme}> + <TableChart {...transformProps(testData.comparison)} sticky={false} /> + </ThemeProvider>, + ); + + const groupHeaders = screen.getAllByRole('columnheader'); + expect(groupHeaders[0]).toHaveTextContent('metric_1'); + expect(groupHeaders[1]).toHaveTextContent('metric_2'); + }); }); it('render cell bars properly, and only when it is toggled on in both regular and percent metrics', () => { diff --git a/superset-frontend/plugins/plugin-chart-table/test/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-table/test/buildQuery.test.ts index f110b424c9bb44a303796530bf52806d8c37bcc7..4badcc673aceed9a5a009aa4251c6e9f77dd48ba 100644 --- a/superset-frontend/plugins/plugin-chart-table/test/buildQuery.test.ts +++ b/superset-frontend/plugins/plugin-chart-table/test/buildQuery.test.ts @@ -148,14 +148,5 @@ describe('plugin-chart-table', () => { expect(queries[1].extras?.time_grain_sqla).toEqual(TimeGranularity.MONTH); expect(queries[1].extras?.where).toEqual("(status IN ('In Process'))"); }); - it('should not include time_grain_sqla in extras if temporal colum is not used and keep the rest', () => { - const { queries } = buildQuery(extraQueryFormData); - // Extras in regular query - expect(queries[0].extras?.time_grain_sqla).toBeUndefined(); - expect(queries[0].extras?.where).toEqual("(status IN ('In Process'))"); - // Extras in summary query - expect(queries[1].extras?.time_grain_sqla).toBeUndefined(); - expect(queries[1].extras?.where).toEqual("(status IN ('In Process'))"); - }); }); }); diff --git a/superset-frontend/spec/helpers/shim.tsx b/superset-frontend/spec/helpers/shim.tsx index 151351c2aefddfc207eacdfaa72b0307003c8ec5..af5ea846815355bf32800e739fd73dad142a7ebf 100644 --- a/superset-frontend/spec/helpers/shim.tsx +++ b/superset-frontend/spec/helpers/shim.tsx @@ -92,7 +92,7 @@ jest.mock('rehype-raw', () => () => jest.fn()); // Mocks the Icon component due to its async nature // Tests should override this when needed -jest.mock('src/components/Icons/Icon', () => ({ +jest.mock('src/components/Icons/AsyncIcon', () => ({ __esModule: true, default: ({ fileName, diff --git a/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx b/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx index 696310fce93b64a40a8373edf8c06204cf1bff52..9e2e89aad03c398d9dae571dd24180d2918ee9b4 100644 --- a/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx +++ b/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx @@ -20,7 +20,7 @@ import { useDispatch } from 'react-redux'; import { t } from '@superset-ui/core'; import { Dropdown } from 'src/components/Dropdown'; import { Menu } from 'src/components/Menu'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { queryEditorSetQueryLimit } from 'src/SqlLab/actions/sqlLab'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; import Button from 'src/components/Button'; diff --git a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx index c120d51446c9e8bd639d01bd92d58b92f463db2a..547808a7ba18454024d745c16fcae14df6853841 100644 --- a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx +++ b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx @@ -32,7 +32,7 @@ import { import TableView from 'src/components/TableView'; import Button from 'src/components/Button'; import { fDuration, extendedDayjs } from 'src/utils/dates'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Label from 'src/components/Label'; import { Tooltip } from 'src/components/Tooltip'; import { SqlLabRootState } from 'src/SqlLab/types'; diff --git a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx index 4d6d4ee6c1deb3ca2700356425fef4b59e91ddcb..9cd7669c3c5fce35431243037dcfe8ca0221b17a 100644 --- a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx +++ b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx @@ -79,7 +79,7 @@ import { LOG_ACTIONS_SQLLAB_CREATE_CHART, LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV, } from 'src/logger/LogUtils'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { findPermission } from 'src/utils/findPermission'; import ExploreCtasResultsButton from '../ExploreCtasResultsButton'; import ExploreResultsButton from '../ExploreResultsButton'; diff --git a/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx b/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx index 815a405663292df4d7ea533f966fafa646ec714e..8086b34747ac7b98917fd4ab68989408c577b761 100644 --- a/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx +++ b/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx @@ -21,7 +21,7 @@ import { useMemo, FC, ReactElement } from 'react'; import { t, styled, useTheme, SupersetTheme } from '@superset-ui/core'; import Button from 'src/components/Button'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { DropdownButton } from 'src/components/DropdownButton'; import { detectOS } from 'src/utils/common'; import { QueryButtonProps } from 'src/SqlLab/types'; diff --git a/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/index.tsx b/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/index.tsx index 4b0eab7be465399732026e487cb658495d113b66..8ca8eba893ea094d477df6c4cee6bec4c6cae0ed 100644 --- a/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/index.tsx +++ b/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/index.tsx @@ -17,7 +17,7 @@ * under the License. */ import { t, useTheme } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { DropdownButton } from 'src/components/DropdownButton'; import Button from 'src/components/Button'; diff --git a/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/index.tsx b/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/index.tsx index 0216078e0db5cc5dde5c5723ad7c9336bb14a1d7..e641c0f349df3a23072c5b9683f285bdc6594a11 100644 --- a/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/index.tsx +++ b/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/index.tsx @@ -24,7 +24,7 @@ import { css, } from '@superset-ui/core'; import Button from 'src/components/Button'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import withToasts from 'src/components/MessageToasts/withToasts'; import CopyToClipboard from 'src/components/CopyToClipboard'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; diff --git a/superset-frontend/src/SqlLab/components/ShowSQL/index.tsx b/superset-frontend/src/SqlLab/components/ShowSQL/index.tsx index 525baa7b0b3b5ea80e65d7bfeed786d7115db57c..409cd5af464b81d39d1254faae07b3fee7131516 100644 --- a/superset-frontend/src/SqlLab/components/ShowSQL/index.tsx +++ b/superset-frontend/src/SqlLab/components/ShowSQL/index.tsx @@ -21,7 +21,7 @@ import sql from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql'; import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github'; import { IconTooltip } from 'src/components/IconTooltip'; import ModalTrigger from 'src/components/ModalTrigger'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; SyntaxHighlighter.registerLanguage('sql', sql); diff --git a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx index 7ddf8f2b077bb90cc05bd1155ff3d340add006fd..af91855fb01775ec881695f3d97a8ab121a27a15 100644 --- a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx +++ b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx @@ -25,7 +25,7 @@ import { css, styled, t, useTheme } from '@superset-ui/core'; import { removeTables, setActiveSouthPaneTab } from 'src/SqlLab/actions/sqlLab'; import Label from 'src/components/Label'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { SqlLabRootState } from 'src/SqlLab/types'; import QueryHistory from '../QueryHistory'; import { diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx index 88646a67721bc62d3aa765e16241b730bc50bd7b..8df7d4a0ceea9823ea01d92cadfa5587b4fa414b 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx @@ -61,7 +61,7 @@ import { Skeleton } from 'src/components'; import { Switch } from 'src/components/Switch'; import { Input } from 'src/components/Input'; import { Menu } from 'src/components/Menu'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { detectOS } from 'src/utils/common'; import { addNewQueryEditor, diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx index 59fd26023f716c6cc5a729dc5e0d52e7295b4e0c..d896da2a9f09623e8d12ca3e5c30868fedeee7c5 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx @@ -36,7 +36,7 @@ import { import Button from 'src/components/Button'; import { t, styled, css, SupersetTheme } from '@superset-ui/core'; import Collapse from 'src/components/Collapse'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { TableSelectorMultiple } from 'src/components/TableSelector'; import { IconTooltip } from 'src/components/IconTooltip'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; diff --git a/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx index 77a9338ab4c7ea005af29676e60027c93bb65586..a7be15f8dd9a3cf578e1a150e6fd5779feafbb12 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx @@ -38,7 +38,7 @@ import { toggleLeftBar, } from 'src/SqlLab/actions/sqlLab'; import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types'; -import Icons, { IconType } from 'src/components/Icons'; +import { Icons, IconType } from 'src/components/Icons'; const TabTitleWrapper = styled.div` display: flex; diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx index 272ca6b56774e83032ae9fdf026213fde56eb2bf..8d9a36da55004ec90173876c0b40ce7bb168e124 100644 --- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx +++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx @@ -36,7 +36,7 @@ import * as Actions from 'src/SqlLab/actions/sqlLab'; import { EmptyState } from 'src/components/EmptyState'; import getBootstrapData from 'src/utils/getBootstrapData'; import { locationContext } from 'src/pages/SqlLab/LocationContext'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import SqlEditor from '../SqlEditor'; import SqlEditorTabHeader from '../SqlEditorTabHeader'; diff --git a/superset-frontend/src/SqlLab/components/TableElement/index.tsx b/superset-frontend/src/SqlLab/components/TableElement/index.tsx index 8185448ba0d05e26b966fc2eaf1dd508fe2003dd..7768b311820bfb82620b5dc576f74d7d8052f00d 100644 --- a/superset-frontend/src/SqlLab/components/TableElement/index.tsx +++ b/superset-frontend/src/SqlLab/components/TableElement/index.tsx @@ -45,7 +45,7 @@ import ModalTrigger from 'src/components/ModalTrigger'; import Loading from 'src/components/Loading'; import useEffectEvent from 'src/hooks/useEffectEvent'; import { ActionType } from 'src/types/Action'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import ColumnElement, { ColumnKeyTypeType } from '../ColumnElement'; import ShowSQL from '../ShowSQL'; @@ -263,7 +263,10 @@ const TableElement = ({ table, ...props }: TableElementProps) => { className="pull-left m-l-2" tooltip={t('View keys & indexes (%s)', tableData.indexes.length)} > - <Icons.KeyOutlined iconSize="s" /> + <Icons.TableOutlined + iconSize="m" + iconColor={theme.colors.primary.dark2} + /> </IconTooltip> } /> diff --git a/superset-frontend/src/SqlLab/components/TablePreview/index.tsx b/superset-frontend/src/SqlLab/components/TablePreview/index.tsx index 20fb6b7bc0084458b7ae8b4c5317b7db6138e11f..ad224894b0f780009f4c1002a92175a29bdc7af2 100644 --- a/superset-frontend/src/SqlLab/components/TablePreview/index.tsx +++ b/superset-frontend/src/SqlLab/components/TablePreview/index.tsx @@ -30,7 +30,7 @@ import { t, } from '@superset-ui/core'; import AutoSizer from 'react-virtualized-auto-sizer'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import type { SqlLabRootState } from 'src/SqlLab/types'; import { Skeleton, AntdBreadcrumb as Breadcrumb, Button } from 'src/components'; import { Dropdown } from 'src/components/Dropdown'; diff --git a/superset-frontend/src/assets/images/icons/big-number-chart-tile.svg b/superset-frontend/src/assets/images/icons/big_number_chart_tile.svg similarity index 100% rename from superset-frontend/src/assets/images/icons/big-number-chart-tile.svg rename to superset-frontend/src/assets/images/icons/big_number_chart_tile.svg diff --git a/superset-frontend/src/assets/images/icons/checkbox-half.svg b/superset-frontend/src/assets/images/icons/checkbox_half.svg similarity index 100% rename from superset-frontend/src/assets/images/icons/checkbox-half.svg rename to superset-frontend/src/assets/images/icons/checkbox_half.svg diff --git a/superset-frontend/src/assets/images/icons/checkbox-off.svg b/superset-frontend/src/assets/images/icons/checkbox_off.svg similarity index 100% rename from superset-frontend/src/assets/images/icons/checkbox-off.svg rename to superset-frontend/src/assets/images/icons/checkbox_off.svg diff --git a/superset-frontend/src/assets/images/icons/checkbox-on.svg b/superset-frontend/src/assets/images/icons/checkbox_on.svg similarity index 100% rename from superset-frontend/src/assets/images/icons/checkbox-on.svg rename to superset-frontend/src/assets/images/icons/checkbox_on.svg diff --git a/superset-frontend/src/components/AlteredSliceTag/index.tsx b/superset-frontend/src/components/AlteredSliceTag/index.tsx index a12d035e964dc4d0e50410ed70743926777c2ebe..d433fe532671eea84879b26844ef084b3dfe572b 100644 --- a/superset-frontend/src/components/AlteredSliceTag/index.tsx +++ b/superset-frontend/src/components/AlteredSliceTag/index.tsx @@ -23,7 +23,7 @@ import { QueryFormData, t } from '@superset-ui/core'; import { sanitizeFormData } from 'src/explore/exploreUtils/formData'; import getControlsForVizType from 'src/utils/getControlsForVizType'; import Label from 'src/components/Label'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { safeStringify } from 'src/utils/safeStringify'; import { Tooltip } from 'src/components/Tooltip'; import ModalTrigger from '../ModalTrigger'; diff --git a/superset-frontend/src/components/CertifiedBadge/index.tsx b/superset-frontend/src/components/CertifiedBadge/index.tsx index 65179d046eab2cc67141287a92cb7606cc1520a8..2af0f864d58686459d0668ea6c7eae59e7982f5e 100644 --- a/superset-frontend/src/components/CertifiedBadge/index.tsx +++ b/superset-frontend/src/components/CertifiedBadge/index.tsx @@ -17,7 +17,7 @@ * under the License. */ import { t, useTheme } from '@superset-ui/core'; -import Icons, { IconType } from 'src/components/Icons'; +import { Icons, IconType } from 'src/components/Icons'; import { Tooltip } from 'src/components/Tooltip'; export interface CertifiedBadgeProps { diff --git a/superset-frontend/src/components/Chart/DisabledMenuItemTooltip.tsx b/superset-frontend/src/components/Chart/DisabledMenuItemTooltip.tsx index 33eb662114520a13d1ecf2aa41a90065e6675a42..89ceddb9ea9c3e5944278aedb0b85f1c3c653d24 100644 --- a/superset-frontend/src/components/Chart/DisabledMenuItemTooltip.tsx +++ b/superset-frontend/src/components/Chart/DisabledMenuItemTooltip.tsx @@ -19,7 +19,7 @@ import { ReactNode } from 'react'; import { css, SupersetTheme } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { Tooltip } from 'src/components/Tooltip'; export const MenuItemTooltip = ({ diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx index 4752d9b6c100850d2eaadfdafe2b18557d8aab4d..73920dfcaab021cd350911312019c898cb0b9070 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx @@ -45,7 +45,7 @@ import { import rison from 'rison'; import { debounce } from 'lodash'; import { FixedSizeList as List } from 'react-window'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { Input } from 'src/components/Input'; import { useToasts } from 'src/components/MessageToasts/withToasts'; import Loading from 'src/components/Loading'; diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailTableControls.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailTableControls.tsx index 378f9e80999569d26117a2e7fbfdc28041917cf2..1c756dd9c563ce2ca208b52ee9b802d6c0e8cbf1 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailTableControls.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailTableControls.tsx @@ -27,7 +27,7 @@ import { useTheme, } from '@superset-ui/core'; import RowCountLabel from 'src/explore/components/RowCountLabel'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; export type TableControlsProps = { filters: BinaryQueryObjectFilterClause[]; diff --git a/superset-frontend/src/components/CopyToClipboard/CopyToClipboard.stories.tsx b/superset-frontend/src/components/CopyToClipboard/CopyToClipboard.stories.tsx index 73880a7ac9859b131a95f2c5368cd05895ef9e7d..bd82ec84a4338e4cdee75f357e90e5dc32a98166 100644 --- a/superset-frontend/src/components/CopyToClipboard/CopyToClipboard.stories.tsx +++ b/superset-frontend/src/components/CopyToClipboard/CopyToClipboard.stories.tsx @@ -17,7 +17,7 @@ * under the License. */ import Button from 'src/components/Button'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import ToastContainer from 'src/components/MessageToasts/ToastContainer'; import CopyToClipboard from '.'; diff --git a/superset-frontend/src/components/Datasource/CollectionTable.tsx b/superset-frontend/src/components/Datasource/CollectionTable.tsx index 22e9df44f3a305d0afba8489d84b309f03a090e4..5147a552c8347408eb803e23bd3c3f72a404853d 100644 --- a/superset-frontend/src/components/Datasource/CollectionTable.tsx +++ b/superset-frontend/src/components/Datasource/CollectionTable.tsx @@ -29,7 +29,7 @@ import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; import { t, styled } from '@superset-ui/core'; import Button from 'src/components/Button'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Fieldset from './Fieldset'; import { recurseReactClone } from './utils'; diff --git a/superset-frontend/src/components/Datasource/DatasourceEditor.jsx b/superset-frontend/src/components/Datasource/DatasourceEditor.jsx index 057cff7b346afcd14f7b93b981850f09ee16d924..dce962a6ce9592e40ea39135fca3f7b12136f052 100644 --- a/superset-frontend/src/components/Datasource/DatasourceEditor.jsx +++ b/superset-frontend/src/components/Datasource/DatasourceEditor.jsx @@ -51,7 +51,7 @@ import TextControl from 'src/explore/components/controls/TextControl'; import TextAreaControl from 'src/explore/components/controls/TextAreaControl'; import SpatialControl from 'src/explore/components/controls/SpatialControl'; import withToasts from 'src/components/MessageToasts/withToasts'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import CurrencyControl from 'src/explore/components/controls/CurrencyControl'; import CollectionTable from './CollectionTable'; import Fieldset from './Fieldset'; diff --git a/superset-frontend/src/components/Datasource/DatasourceModal.tsx b/superset-frontend/src/components/Datasource/DatasourceModal.tsx index e25f4d349aacfb80d7ca4ca875ce592a3de95db6..1055e51f4447b227b16388c9550d2358c0bcce8f 100644 --- a/superset-frontend/src/components/Datasource/DatasourceModal.tsx +++ b/superset-frontend/src/components/Datasource/DatasourceModal.tsx @@ -31,7 +31,7 @@ import { css, } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Modal from 'src/components/Modal'; import AsyncEsmComponent from 'src/components/AsyncEsmComponent'; import ErrorMessageWithStackTrace from 'src/components/ErrorMessage/ErrorMessageWithStackTrace'; diff --git a/superset-frontend/src/components/Dropdown/index.tsx b/superset-frontend/src/components/Dropdown/index.tsx index faf12c09733a59e4b3b754ecd756234a26f6167c..99079e87adc81f8d9aadf35a4305300e8bd2936d 100644 --- a/superset-frontend/src/components/Dropdown/index.tsx +++ b/superset-frontend/src/components/Dropdown/index.tsx @@ -29,7 +29,7 @@ import { DropdownProps as AntdDropdownProps, } from 'antd-v5'; import { styled } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; const MenuDots = styled.div` width: ${({ theme }) => theme.gridUnit * 0.75}px; diff --git a/superset-frontend/src/components/DropdownContainer/DropdownContainer.test.tsx b/superset-frontend/src/components/DropdownContainer/DropdownContainer.test.tsx index d5475c01d5e5786fbebd2d37b6381ebc30dbba25..7833a1313befb4cd7a469ed4614a71acf8f34fb5 100644 --- a/superset-frontend/src/components/DropdownContainer/DropdownContainer.test.tsx +++ b/superset-frontend/src/components/DropdownContainer/DropdownContainer.test.tsx @@ -18,7 +18,7 @@ */ import { screen, render, userEvent } from 'spec/helpers/testing-library'; import Button from '../Button'; -import Icons from '../Icons'; +import { Icons } from '../Icons'; import DropdownContainer from '.'; const generateItems = (n: number) => diff --git a/superset-frontend/src/components/DropdownContainer/index.tsx b/superset-frontend/src/components/DropdownContainer/index.tsx index 0d2da00f8d7c4b5a500a6046dd3f2c2f0b0db036..0735ba289eabef7e8d0d505e277586b84ae2d984 100644 --- a/superset-frontend/src/components/DropdownContainer/index.tsx +++ b/superset-frontend/src/components/DropdownContainer/index.tsx @@ -35,7 +35,7 @@ import { Global } from '@emotion/react'; import { css, t, useTheme, usePrevious } from '@superset-ui/core'; import { useResizeDetector } from 'react-resize-detector'; import Badge from '../Badge'; -import Icons from '../Icons'; +import { Icons } from '../Icons'; import Button from '../Button'; import Popover from '../Popover'; import { Tooltip } from '../Tooltip'; diff --git a/superset-frontend/src/components/ErrorMessage/BasicErrorAlert.test.tsx b/superset-frontend/src/components/ErrorMessage/BasicErrorAlert.test.tsx index 21b82cf20c68347d7541c5751b2db32677d35972..3ac2729f220f6846c85997d7f3dbac2d2d7c5ad2 100644 --- a/superset-frontend/src/components/ErrorMessage/BasicErrorAlert.test.tsx +++ b/superset-frontend/src/components/ErrorMessage/BasicErrorAlert.test.tsx @@ -22,7 +22,7 @@ import { ErrorLevel, supersetTheme } from '@superset-ui/core'; import BasicErrorAlert from './BasicErrorAlert'; jest.mock( - 'src/components/Icons/Icon', + 'src/components/Icons/AsyncIcon', () => ({ fileName }: { fileName: string }) => ( <span role="img" aria-label={fileName.replace('_', '-')} /> diff --git a/superset-frontend/src/components/ErrorMessage/BasicErrorAlert.tsx b/superset-frontend/src/components/ErrorMessage/BasicErrorAlert.tsx index acebb720f4bc8dbb287587a3c3d3844d50452f4c..1bad6a8a5293f7fac3996c1b702089850b10b480 100644 --- a/superset-frontend/src/components/ErrorMessage/BasicErrorAlert.tsx +++ b/superset-frontend/src/components/ErrorMessage/BasicErrorAlert.tsx @@ -17,7 +17,7 @@ * under the License. */ import { ErrorLevel, styled, useTheme } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; const StyledContainer = styled.div<{ level: ErrorLevel }>` display: flex; diff --git a/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.test.tsx b/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.test.tsx index ec63048197fca3a0bbe4ea4a0dfa8daae372b68d..b9a98b7ced3e3b77e99ecd56ac5bdea6a1446849 100644 --- a/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.test.tsx +++ b/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.test.tsx @@ -22,7 +22,7 @@ import { render, screen, userEvent } from 'spec/helpers/testing-library'; import DatabaseErrorMessage from './DatabaseErrorMessage'; jest.mock( - 'src/components/Icons/Icon', + 'src/components/Icons/AsyncIcon', () => ({ fileName }: { fileName: string }) => ( <span role="img" aria-label={fileName.replace('_', '-')} /> diff --git a/superset-frontend/src/components/ErrorMessage/DatasetNotFoundErrorMessage.test.tsx b/superset-frontend/src/components/ErrorMessage/DatasetNotFoundErrorMessage.test.tsx index d7e08dce1e5f9ed7f3992df11a79e73fc23b4a12..f54e29272cc4da06954329d93c1a426225e32847 100644 --- a/superset-frontend/src/components/ErrorMessage/DatasetNotFoundErrorMessage.test.tsx +++ b/superset-frontend/src/components/ErrorMessage/DatasetNotFoundErrorMessage.test.tsx @@ -22,7 +22,7 @@ import { render, screen } from 'spec/helpers/testing-library'; import DatasetNotFoundErrorMessage from './DatasetNotFoundErrorMessage'; jest.mock( - 'src/components/Icons/Icon', + 'src/components/Icons/AsyncIcon', () => ({ fileName }: { fileName: string }) => ( <span role="img" aria-label={fileName.replace('_', '-')} /> diff --git a/superset-frontend/src/components/ErrorMessage/ErrorAlert.tsx b/superset-frontend/src/components/ErrorMessage/ErrorAlert.tsx index 8e0d3ea513c663eb2bdc8898a5455b675030ee3b..b04b0f1f0afb3080e8cf984b0766072347ced032 100644 --- a/superset-frontend/src/components/ErrorMessage/ErrorAlert.tsx +++ b/superset-frontend/src/components/ErrorMessage/ErrorAlert.tsx @@ -19,7 +19,7 @@ import { useState } from 'react'; import { Tooltip } from 'src/components/Tooltip'; import Modal from 'src/components/Modal'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Alert from 'src/components/Alert'; import { t, useTheme } from '@superset-ui/core'; diff --git a/superset-frontend/src/components/ErrorMessage/ErrorMessageWithStackTrace.test.tsx b/superset-frontend/src/components/ErrorMessage/ErrorMessageWithStackTrace.test.tsx index ecb8a79c3bb9270dd57a9135ce0b38d5d74f4606..f9a3415ace23aab3ccf659917b2121286531a30e 100644 --- a/superset-frontend/src/components/ErrorMessage/ErrorMessageWithStackTrace.test.tsx +++ b/superset-frontend/src/components/ErrorMessage/ErrorMessageWithStackTrace.test.tsx @@ -23,7 +23,7 @@ import ErrorMessageWithStackTrace from './ErrorMessageWithStackTrace'; import BasicErrorAlert from './BasicErrorAlert'; jest.mock( - 'src/components/Icons/Icon', + 'src/components/Icons/AsyncIcon', () => ({ fileName }: { fileName: string }) => ( <span role="img" aria-label={fileName.replace('_', '-')} /> diff --git a/superset-frontend/src/components/ErrorMessage/FrontendNetworkErrorMessage.test.tsx b/superset-frontend/src/components/ErrorMessage/FrontendNetworkErrorMessage.test.tsx index bebed79bbe2d929b7245198453fced30a64f84ea..c364501c67d01cc1b624626d110037333207177e 100644 --- a/superset-frontend/src/components/ErrorMessage/FrontendNetworkErrorMessage.test.tsx +++ b/superset-frontend/src/components/ErrorMessage/FrontendNetworkErrorMessage.test.tsx @@ -22,7 +22,7 @@ import { render, screen, userEvent } from 'spec/helpers/testing-library'; import FrontendNetworkErrorMessage from './FrontendNetworkErrorMessage'; jest.mock( - 'src/components/Icons/Icon', + 'src/components/Icons/AsyncIcon', () => ({ fileName }: { fileName: string }) => ( <span role="img" aria-label={fileName.replace('_', '-')} /> diff --git a/superset-frontend/src/components/ErrorMessage/IssueCode.tsx b/superset-frontend/src/components/ErrorMessage/IssueCode.tsx index 35cf3ec8d9b9d1f3e2f132657232a99e739d7250..af8ab9c79108de75f2f7cd9b05b375c84317564a 100644 --- a/superset-frontend/src/components/ErrorMessage/IssueCode.tsx +++ b/superset-frontend/src/components/ErrorMessage/IssueCode.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { useTheme } from '@superset-ui/core'; interface IssueCodeProps { diff --git a/superset-frontend/src/components/ErrorMessage/ParameterErrorMessage.test.tsx b/superset-frontend/src/components/ErrorMessage/ParameterErrorMessage.test.tsx index cbc193a59c01c89011d5254aef8a9f62603e9c51..e2fc8b92e804d4ee77c3f7124a88975ccc73d587 100644 --- a/superset-frontend/src/components/ErrorMessage/ParameterErrorMessage.test.tsx +++ b/superset-frontend/src/components/ErrorMessage/ParameterErrorMessage.test.tsx @@ -22,7 +22,7 @@ import { render, screen, userEvent } from 'spec/helpers/testing-library'; import ParameterErrorMessage from './ParameterErrorMessage'; jest.mock( - 'src/components/Icons/Icon', + 'src/components/Icons/AsyncIcon', () => ({ fileName }: { fileName: string }) => ( <span role="img" aria-label={fileName.replace('_', '-')} /> diff --git a/superset-frontend/src/components/ErrorMessage/TimeoutErrorMessage.test.tsx b/superset-frontend/src/components/ErrorMessage/TimeoutErrorMessage.test.tsx index f9fe89c5df11992fcc407aab9dd5b3e79ad97903..8fb2e3d041d983126a14d9cab5885d180df79383 100644 --- a/superset-frontend/src/components/ErrorMessage/TimeoutErrorMessage.test.tsx +++ b/superset-frontend/src/components/ErrorMessage/TimeoutErrorMessage.test.tsx @@ -22,7 +22,7 @@ import { render, screen, userEvent } from 'spec/helpers/testing-library'; import TimeoutErrorMessage from './TimeoutErrorMessage'; jest.mock( - 'src/components/Icons/Icon', + 'src/components/Icons/AsyncIcon', () => ({ fileName }: { fileName: string }) => ( <span role="img" aria-label={fileName.replace('_', '-')} /> diff --git a/superset-frontend/src/components/FaveStar/index.tsx b/superset-frontend/src/components/FaveStar/index.tsx index 262be12df3bccfd40b7689640df4e6f4556cc745..83a75b1ad63e92fede0d94c9c745cbf378d64435 100644 --- a/superset-frontend/src/components/FaveStar/index.tsx +++ b/superset-frontend/src/components/FaveStar/index.tsx @@ -21,7 +21,7 @@ import { useCallback, useEffect, MouseEvent } from 'react'; import { css, t, styled, useTheme } from '@superset-ui/core'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; export interface FaveStarProps { itemId: number; diff --git a/superset-frontend/src/components/Form/Form.tsx b/superset-frontend/src/components/Form/Form.tsx index 66a9f58d7f2280869f4fce74ff393ff695b8d34f..d827dd5871a042e291b140025bf89bd3bc3490d7 100644 --- a/superset-frontend/src/components/Form/Form.tsx +++ b/superset-frontend/src/components/Form/Form.tsx @@ -29,8 +29,16 @@ const StyledForm = styled(AntdForm)` } `; -export default function Form(props: FormProps) { +function Form(props: FormProps) { return <StyledForm {...props} />; } +export default Object.assign(Form, { + useForm: AntdForm.useForm, + Item: AntdForm.Item, + List: AntdForm.List, + ErrorList: AntdForm.ErrorList, + Provider: AntdForm.Provider, +}); + export type { FormProps }; diff --git a/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx b/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx index c669c1d2d18725ef33571ddfe4b4ceccda9b06fc..5ef9024dea2aa2ee522c9c66937a7df07e19eb18 100644 --- a/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx +++ b/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx @@ -20,7 +20,7 @@ import { styled, css, SupersetTheme, t } from '@superset-ui/core'; import { Tooltip } from 'src/components/Tooltip'; import { Input } from 'src/components/Input'; import InfoTooltip from 'src/components/InfoTooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Button from 'src/components/Button'; import errorIcon from 'src/assets/images/icons/error.svg'; import FormItem from './FormItem'; diff --git a/superset-frontend/src/components/GridTable/Header.test.tsx b/superset-frontend/src/components/GridTable/Header.test.tsx index 7657dbe7b4a77ff0c8afd6891747451a6910f7fb..47b16115ff430e5e10bc1658e48c9eea8d8bbd25 100644 --- a/superset-frontend/src/components/GridTable/Header.test.tsx +++ b/superset-frontend/src/components/GridTable/Header.test.tsx @@ -26,9 +26,11 @@ jest.mock('src/components/Dropdown', () => ({ })); jest.mock('src/components/Icons', () => ({ - Sort: () => <div data-test="mock-sort" />, - SortAsc: () => <div data-test="mock-sort-asc" />, - SortDesc: () => <div data-test="mock-sort-desc" />, + Icons: { + Sort: jest.fn(() => <div data-test="mock-sort" />), + SortAsc: jest.fn(() => <div data-test="mock-sort-asc" />), + SortDesc: jest.fn(() => <div data-test="mock-sort-desc" />), + }, })); class MockApi extends EventTarget { diff --git a/superset-frontend/src/components/GridTable/Header.tsx b/superset-frontend/src/components/GridTable/Header.tsx index c613f9b5d2eda99d8e392a5720ceab3ff9b17412..03af51ccf5263d1cc945a7a3a0c7d5424af75a40 100644 --- a/superset-frontend/src/components/GridTable/Header.tsx +++ b/superset-frontend/src/components/GridTable/Header.tsx @@ -20,7 +20,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { styled, useTheme, t } from '@superset-ui/core'; import type { Column, GridApi } from 'ag-grid-community'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { PIVOT_COL_ID } from './constants'; import HeaderMenu from './HeaderMenu'; diff --git a/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx b/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx index 65f486c34dafe481ee8ae514f2390dff612394bf..2b8dbc11abb3d66424804f808717a8ed3d0e1e52 100644 --- a/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx +++ b/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx @@ -58,17 +58,6 @@ jest.mock('src/components/Menu', () => { return { Menu }; }); -jest.mock('src/components/Icons', () => ({ - DownloadOutlined: () => <div data-test="mock-DownloadOutlined" />, - CopyOutlined: () => <div data-test="mock-CopyOutlined" />, - UnlockOutlined: () => <div data-test="mock-UnlockOutlined" />, - VerticalRightOutlined: () => <div data-test="mock-VerticalRightOutlined" />, - VerticalLeftOutlined: () => <div data-test="mock-VerticalLeftOutlined" />, - EyeInvisibleOutlined: () => <div data-test="mock-EyeInvisibleOutlined" />, - EyeOutlined: () => <div data-test="mock-EyeOutlined" />, - ColumnWidthOutlined: () => <div data-test="mock-column-width" />, -})); - jest.mock('src/components/Dropdown', () => ({ MenuDotsDropdown: ({ overlay }: { overlay: React.ReactChild }) => ( <div data-test="mock-Dropdown">{overlay}</div> diff --git a/superset-frontend/src/components/GridTable/HeaderMenu.tsx b/superset-frontend/src/components/GridTable/HeaderMenu.tsx index 5d327104b5c56f71c8da011631a686d20730bf5f..c9e14c4b1a827b4acff7e5265607628f68b44cd1 100644 --- a/superset-frontend/src/components/GridTable/HeaderMenu.tsx +++ b/superset-frontend/src/components/GridTable/HeaderMenu.tsx @@ -20,7 +20,7 @@ import { useCallback } from 'react'; import { styled, t } from '@superset-ui/core'; import type { Column, ColumnPinnedType, GridApi } from 'ag-grid-community'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { MenuDotsDropdown, DropdownProps } from 'src/components/Dropdown'; import { Menu } from 'src/components/Menu'; import copyTextToClipboard from 'src/utils/copy'; diff --git a/superset-frontend/src/components/IconButton/IconButton.stories.tsx b/superset-frontend/src/components/IconButton/IconButton.stories.tsx index 0cc10f32f82ba26a330bdbecc972a8497c18fe04..3b47e4a0c321b8d602ae81c897ad2ccfac5896ed 100644 --- a/superset-frontend/src/components/IconButton/IconButton.stories.tsx +++ b/superset-frontend/src/components/IconButton/IconButton.stories.tsx @@ -16,42 +16,37 @@ * specific language governing permissions and limitations * under the License. */ -import IconButton, { IconButtonProps } from '.'; +import { Meta, StoryObj } from '@storybook/react'; +import { IconButton } from 'src/components/IconButton'; -export default { - title: 'IconButton', +const meta: Meta<typeof IconButton> = { + title: 'Components/IconButton', component: IconButton, + argTypes: { + onClick: { action: 'clicked' }, + }, + parameters: { + a11y: { + enabled: true, + }, + }, }; -export const InteractiveIconButton = (args: IconButtonProps) => ( - <IconButton - buttonText={args.buttonText} - altText={args.altText} - icon={args.icon} - href={args.href} - target={args.target} - htmlType={args.htmlType} - /> -); +export default meta; -InteractiveIconButton.args = { - buttonText: 'This is the IconButton text', - altText: 'This is an example of non-default alt text', - href: 'https://preset.io/', - target: '_blank', +type Story = StoryObj<typeof IconButton>; + +export const Default: Story = { + args: { + buttonText: 'Default IconButton', + altText: 'Default icon button alt text', + }, }; -InteractiveIconButton.argTypes = { - icon: { - defaultValue: '/images/icons/sql.svg', - control: { - type: 'select', - }, - options: [ - '/images/icons/sql.svg', - '/images/icons/server.svg', - '/images/icons/image.svg', - 'Click to see example alt text', - ], +export const CustomIcon: Story = { + args: { + buttonText: 'Custom icon IconButton', + altText: 'Custom icon button alt text', + icon: '/images/sqlite.png', }, }; diff --git a/superset-frontend/src/components/IconButton/IconButton.test.tsx b/superset-frontend/src/components/IconButton/IconButton.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dc45fe003ecc6a8d886791e88f64e00a1eccaa43 --- /dev/null +++ b/superset-frontend/src/components/IconButton/IconButton.test.tsx @@ -0,0 +1,90 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { render, screen, fireEvent } from 'spec/helpers/testing-library'; +import { IconButton } from 'src/components/IconButton'; + +const defaultProps = { + buttonText: 'This is the IconButton text', + icon: '/images/icons/sql.svg', +}; + +describe('IconButton', () => { + it('renders an IconButton with icon and text', () => { + render(<IconButton {...defaultProps} />); + + const icon = screen.getByRole('img'); + const buttonText = screen.getByText(/this is the iconbutton text/i); + + expect(icon).toBeVisible(); + expect(buttonText).toBeVisible(); + }); + + it('is keyboard accessible and has correct aria attributes', () => { + render(<IconButton {...defaultProps} />); + + const button = screen.getByRole('button'); + + expect(button).toHaveAttribute('tabIndex', '0'); + expect(button).toHaveAttribute('aria-label', defaultProps.buttonText); + }); + + it('handles Enter and Space key presses', () => { + const mockOnClick = jest.fn(); + render(<IconButton {...defaultProps} onClick={mockOnClick} />); + + const button = screen.getByRole('button'); + + fireEvent.keyDown(button, { key: 'Enter', code: 'Enter' }); + expect(mockOnClick).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(button, { key: ' ', code: 'Space' }); + expect(mockOnClick).toHaveBeenCalledTimes(2); + }); + + it('uses custom alt text when provided', () => { + const customAltText = 'Custom Alt Text'; + render( + <IconButton + buttonText="Custom Alt Text Button" + icon="/images/icons/sql.svg" + altText={customAltText} + />, + ); + + const icon = screen.getByAltText(customAltText); + expect(icon).toBeVisible(); + }); + + it('displays tooltip with button text', () => { + render(<IconButton {...defaultProps} />); + + const tooltipTrigger = screen.getByText(/this is the iconbutton text/i); + expect(tooltipTrigger).toBeVisible(); + }); + + it('calls onClick handler when clicked', () => { + const mockOnClick = jest.fn(); + render(<IconButton {...defaultProps} onClick={mockOnClick} />); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/superset-frontend/src/components/IconButton/index.tsx b/superset-frontend/src/components/IconButton/index.tsx index 654e4089f5a9cc46d553445f8a79aaf54a017a0f..b8ec447a5ea70b4ff701d118b2cb09b3c326182c 100644 --- a/superset-frontend/src/components/IconButton/index.tsx +++ b/superset-frontend/src/components/IconButton/index.tsx @@ -16,129 +16,90 @@ * specific language governing permissions and limitations * under the License. */ -import { styled } from '@superset-ui/core'; -import Button, { ButtonProps as AntdButtonProps } from 'src/components/Button'; -import Icons from 'src/components/Icons'; -import LinesEllipsis from 'react-lines-ellipsis'; -export interface IconButtonProps extends AntdButtonProps { +// eslint-disable-next-line +import { Typography } from 'src/components'; +import { Tooltip } from 'src/components/Tooltip'; +import Card, { CardProps } from 'src/components/Card'; +import { Icons } from 'src/components/Icons'; +import { SupersetTheme, css } from '@superset-ui/core'; + +export interface IconButtonProps extends CardProps { buttonText: string; icon: string; altText?: string; } -const StyledButton = styled(Button)` - height: auto; - display: flex; - flex-direction: column; - padding: 0; -`; - -const StyledImage = styled.div` - padding: ${({ theme }) => theme.gridUnit * 4}px; - height: ${({ theme }) => theme.gridUnit * 18}px; - margin: ${({ theme }) => theme.gridUnit * 3}px 0; - - .default-db-icon { - font-size: 36px; - color: ${({ theme }) => theme.colors.grayscale.base}; - margin-right: 0; - span:first-of-type { - margin-right: 0; - } - } - - &:first-of-type { - margin-right: 0; - } - - img { - width: ${({ theme }) => theme.gridUnit * 10}px; - height: ${({ theme }) => theme.gridUnit * 10}px; - margin: 0; - &:first-of-type { - margin-right: 0; - } - } - svg { - &:first-of-type { - margin-right: 0; +const IconButton: React.FC<IconButtonProps> = ({ + buttonText, + icon, + altText, + ...cardProps +}) => { + const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { + if (e.key === 'Enter' || e.key === ' ') { + if (cardProps.onClick) { + (cardProps.onClick as React.EventHandler<React.SyntheticEvent>)(e); + } + if (e.key === ' ') { + e.preventDefault(); + } } - } -`; - -const StyledInner = styled.div` - max-height: calc(1.5em * 2); - white-space: break-spaces; - - &:first-of-type { - margin-right: 0; - } - - .LinesEllipsis { - &:first-of-type { - margin-right: 0; - } - } -`; - -const StyledBottom = styled.div` - padding: ${({ theme }) => theme.gridUnit * 4}px 0; - border-radius: 0 0 ${({ theme }) => theme.borderRadius}px - ${({ theme }) => theme.borderRadius}px; - background-color: ${({ theme }) => theme.colors.grayscale.light4}; - width: 100%; - line-height: 1.5em; - overflow: hidden; - white-space: no-wrap; - text-overflow: ellipsis; - - &:first-of-type { - margin-right: 0; - } -`; + cardProps.onKeyDown?.(e); + }; -const IconButton = styled( - ({ icon, altText, buttonText, ...props }: IconButtonProps) => ( - <StyledButton {...props}> - <StyledImage> - {icon && <img src={icon} alt={altText} />} - {!icon && ( - <Icons.DatabaseOutlined - className="default-db-icon" - aria-label="default-icon" - /> - )} - </StyledImage> + const renderIcon = () => { + const iconContent = icon ? ( + <img + src={icon} + alt={altText || buttonText} + css={css` + width: 100%; + height: 120px; + object-fit: contain; + `} + /> + ) : ( + <div + css={css` + display: flex; + align-content: center; + align-items: center; + height: 120px; + `} + > + <Icons.DatabaseOutlined + css={css` + font-size: 48px; + `} + aria-label="default-icon" + /> + </div> + ); - <StyledBottom> - <StyledInner> - <LinesEllipsis - text={buttonText} - maxLine="2" - basedOn="words" - trimRight - /> - </StyledInner> - </StyledBottom> - </StyledButton> - ), -)` - text-transform: none; - background-color: ${({ theme }) => theme.colors.grayscale.light5}; - font-weight: ${({ theme }) => theme.typography.weights.normal}; - color: ${({ theme }) => theme.colors.grayscale.dark2}; - border: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; - margin: 0; - width: 100%; + return iconContent; + }; - &:hover, - &:focus { - background-color: ${({ theme }) => theme.colors.grayscale.light5}; - color: ${({ theme }) => theme.colors.grayscale.dark2}; - border: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; - box-shadow: 4px 4px 20px ${({ theme }) => theme.colors.grayscale.light2}; - } -`; + return ( + <Card + hoverable + role="button" + tabIndex={0} + aria-label={buttonText} + onKeyDown={handleKeyDown} + cover={renderIcon()} + css={(theme: SupersetTheme) => ({ + padding: theme.gridUnit * 3, + textAlign: 'center', + ...cardProps.style, + })} + {...cardProps} + > + <Tooltip title={buttonText}> + <Typography.Text ellipsis>{buttonText}</Typography.Text> + </Tooltip> + </Card> + ); +}; -export default IconButton; +export { IconButton }; diff --git a/superset-frontend/src/components/IconTooltip/IconTooltip.stories.tsx b/superset-frontend/src/components/IconTooltip/IconTooltip.stories.tsx index 2e5f3a280a1ca426849b9a873ba531366e2d2d51..44b193e5535a7447e07fb9f3d3f893400e755166 100644 --- a/superset-frontend/src/components/IconTooltip/IconTooltip.stories.tsx +++ b/superset-frontend/src/components/IconTooltip/IconTooltip.stories.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { css, useTheme } from '@superset-ui/core'; import { IconTooltip, Props } from '.'; diff --git a/superset-frontend/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/src/components/Icons/AntdEnhanced.tsx index 42c9ad7463978c03a3455ad5d9c7e68ab686d9c3..57eb41c61d6468827376a5b120e9e1186a79594b 100644 --- a/superset-frontend/src/components/Icons/AntdEnhanced.tsx +++ b/superset-frontend/src/components/Icons/AntdEnhanced.tsx @@ -110,10 +110,15 @@ import { FilterOutlined, UnorderedListOutlined, WarningOutlined, + KeyOutlined, } from '@ant-design/icons'; +import { FC } from 'react'; import { IconType } from './types'; import { BaseIconComponent } from './BaseIcon'; +// partial name matches work too +const EXCLUDED_ICONS = ['TwoTone']; + const AntdIcons = { AlignCenterOutlined, AlignLeftOutlined, @@ -205,19 +210,26 @@ const AntdIcons = { FilterOutlined, UnorderedListOutlined, WarningOutlined, -}; + KeyOutlined, +} as const; -const AntdEnhancedIcons = Object.keys(AntdIcons) - .filter(k => !k.includes('TwoTone')) - .map(k => ({ - [k]: (props: IconType) => ( - <BaseIconComponent - component={AntdIcons[k as keyof typeof AntdIcons]} - fileName={k} - {...props} - /> - ), - })) - .reduce((l, r) => ({ ...l, ...r })); +type AntdIconNames = keyof typeof AntdIcons; -export default AntdEnhancedIcons; +export const antdEnhancedIcons: Record< + AntdIconNames, + FC<IconType> +> = Object.keys(AntdIcons) + .filter(key => !EXCLUDED_ICONS.some(excluded => key.includes(excluded))) + .reduce( + (acc, key) => { + acc[key as AntdIconNames] = (props: IconType) => ( + <BaseIconComponent + component={AntdIcons[key as AntdIconNames]} + fileName={key} + {...props} + /> + ); + return acc; + }, + {} as Record<AntdIconNames, FC<IconType>>, + ); diff --git a/superset-frontend/src/components/Icons/Icon.tsx b/superset-frontend/src/components/Icons/AsyncIcon.tsx similarity index 96% rename from superset-frontend/src/components/Icons/Icon.tsx rename to superset-frontend/src/components/Icons/AsyncIcon.tsx index 3c27ca647074a1d982a63605cfa66a374e35173c..1ce39c25bd61004cb792ed71d2ae4afebfd0b1e2 100644 --- a/superset-frontend/src/components/Icons/Icon.tsx +++ b/superset-frontend/src/components/Icons/AsyncIcon.tsx @@ -22,7 +22,7 @@ import TransparentIcon from 'src/assets/images/icons/transparent.svg'; import { IconType } from './types'; import { BaseIconComponent } from './BaseIcon'; -export const Icon = (props: IconType) => { +const AsyncIcon = (props: IconType) => { const [, setLoaded] = useState(false); const ImportedSVG = useRef<FC<SVGProps<SVGSVGElement>>>(); const { fileName } = props; @@ -51,4 +51,4 @@ export const Icon = (props: IconType) => { ); }; -export default Icon; +export default AsyncIcon; diff --git a/superset-frontend/src/components/Icons/Icons.stories.tsx b/superset-frontend/src/components/Icons/Icons.stories.tsx index 017be295213e7d4a0396888fa424916f4e9ed879..672192e06bcfda705b169becb38186bd79113032 100644 --- a/superset-frontend/src/components/Icons/Icons.stories.tsx +++ b/superset-frontend/src/components/Icons/Icons.stories.tsx @@ -19,13 +19,13 @@ import { useState } from 'react'; import { styled, supersetTheme } from '@superset-ui/core'; import { Input } from 'antd-v5'; -import Icons from '.'; +import { Icons, IconNameType } from '.'; import IconType from './types'; -import Icon from './Icon'; +import { BaseIconComponent } from './BaseIcon'; export default { title: 'Icons', - component: Icon, + component: BaseIconComponent, }; const palette: Record<string, string | null> = { Default: null }; @@ -84,7 +84,7 @@ export const InteractiveIcons = ({ /> <IconSet> {filteredIcons.map(k => { - const IconComponent = Icons[k]; + const IconComponent = Icons[k as IconNameType]; return ( <IconBlock key={k}> <IconComponent {...rest} /> diff --git a/superset-frontend/src/components/Icons/index.tsx b/superset-frontend/src/components/Icons/index.tsx index eb3fa51605b1d7884c6041a057daffe8d70ad07a..0651d529d345b06092a98d10d7436f6c41d5a203 100644 --- a/superset-frontend/src/components/Icons/index.tsx +++ b/superset-frontend/src/components/Icons/index.tsx @@ -18,51 +18,62 @@ */ import { FC } from 'react'; -import { startCase } from 'lodash'; -import AntdEnhancedIcons from './AntdEnhanced'; -import Icon from './Icon'; +import { antdEnhancedIcons } from './AntdEnhanced'; +import AsyncIcon from './AsyncIcon'; import IconType from './types'; -const IconFileNames = [ - // to keep custom - 'ballot', - 'big-number-chart-tile', - 'binoculars', - 'category', - 'certified', - 'checkbox-half', - 'checkbox-off', - 'checkbox-on', - 'circle_solid', - 'drag', - 'error_solid_small_red', - 'error', - 'full', - 'layers', - 'queued', - 'redo', - 'running', - 'slack', - 'square', - 'sort_asc', - 'sort_desc', - 'sort', - 'transparent', - 'triangle_down', - 'undo', -]; +export type { IconType }; +/** + * Filename is going to be inferred from the icon name. + * i.e. BigNumberChartTile => assets/images/icons/big_number_chart_tile + */ +const customIcons = [ + 'Ballot', + 'BigNumberChartTile', + 'Binoculars', + 'Category', + 'Certified', + 'CheckboxHalf', + 'CheckboxOff', + 'CheckboxOn', + 'CircleSolid', + 'Drag', + 'ErrorSolidSmallRed', + 'Error', + 'Full', + 'Layers', + 'Queued', + 'Redo', + 'Running', + 'Slack', + 'Square', + 'SortAsc', + 'SortDesc', + 'Sort', + 'Transparent', + 'TriangleDown', + 'Undo', +] as const; -const iconOverrides: Record<string, FC<IconType>> = {}; -IconFileNames.forEach(fileName => { - const keyName = startCase(fileName).replace(/ /g, ''); - iconOverrides[keyName] = (props: IconType) => ( - <Icon customIcons fileName={fileName} {...props} /> +type CustomIconType = Record<(typeof customIcons)[number], FC<IconType>>; + +const iconOverrides: CustomIconType = {} as CustomIconType; +customIcons.forEach(customIcon => { + const fileName = customIcon + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toLowerCase(); + iconOverrides[customIcon] = (props: IconType) => ( + <AsyncIcon customIcons fileName={fileName} {...props} /> ); }); -export type { IconType }; +export type IconNameType = + | keyof typeof antdEnhancedIcons + | keyof typeof iconOverrides; + +type IconComponentType = Record<IconNameType, FC<IconType>>; -export default { - ...AntdEnhancedIcons, +export const Icons: IconComponentType = { + ...antdEnhancedIcons, ...iconOverrides, }; diff --git a/superset-frontend/src/components/IndeterminateCheckbox/index.tsx b/superset-frontend/src/components/IndeterminateCheckbox/index.tsx index 64cac112d7b2f0dcf770aa8aefffa6b3ade9789d..4cf13aa50c1242fd59b2af43287be702bd336105 100644 --- a/superset-frontend/src/components/IndeterminateCheckbox/index.tsx +++ b/superset-frontend/src/components/IndeterminateCheckbox/index.tsx @@ -26,7 +26,7 @@ import { } from 'react'; import { styled } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; export interface IndeterminateCheckboxProps { indeterminate: boolean; diff --git a/superset-frontend/src/components/InfoTooltip/index.tsx b/superset-frontend/src/components/InfoTooltip/index.tsx index 3ade7f0efa0443d7a591bb4927980c0f646c208d..5062506afca6d5f3af487d01cf2aa7a403e4798b 100644 --- a/superset-frontend/src/components/InfoTooltip/index.tsx +++ b/superset-frontend/src/components/InfoTooltip/index.tsx @@ -19,7 +19,7 @@ import { styled, useTheme, css } from '@superset-ui/core'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { ActionType } from 'src/types/Action'; export interface InfoTooltipProps { diff --git a/superset-frontend/src/components/JsonModal/JsonModal.test.tsx b/superset-frontend/src/components/JsonModal/JsonModal.test.tsx index f0e2230f5a827c1ab13ab22c362b59077ab5b13a..44f01404a3b3055769159864fb958d94633a9942 100644 --- a/superset-frontend/src/components/JsonModal/JsonModal.test.tsx +++ b/superset-frontend/src/components/JsonModal/JsonModal.test.tsx @@ -42,6 +42,21 @@ test('renders JSON object in a tree view in a modal', () => { expect(getByTestId('mock-json-tree')).toBeInTheDocument(); }); +test('renders an object in a tree view in a modal', () => { + const jsonData = { a: 1 }; + const expected = JSON.stringify(jsonData); + const { getByText, getByTestId, queryByTestId } = render( + <JsonModal jsonObject={jsonData} jsonValue={jsonData} modalTitle="title" />, + { + useRedux: true, + }, + ); + expect(queryByTestId('mock-json-tree')).not.toBeInTheDocument(); + const link = getByText(expected); + fireEvent.click(link); + expect(getByTestId('mock-json-tree')).toBeInTheDocument(); +}); + test('renders bigInt value in a number format', () => { expect(convertBigIntStrToNumber('123')).toBe('123'); expect(convertBigIntStrToNumber('some string value')).toBe( diff --git a/superset-frontend/src/components/JsonModal/index.tsx b/superset-frontend/src/components/JsonModal/index.tsx index e599f483dcd2e99e2c5befaf24cebe8debdea9a7..79ff25ef5afc9be7aa7928fa3712bba6906886e6 100644 --- a/superset-frontend/src/components/JsonModal/index.tsx +++ b/superset-frontend/src/components/JsonModal/index.tsx @@ -36,7 +36,7 @@ * under the License. */ import JSONbig from 'json-bigint'; -import { FC } from 'react'; +import { FC, useMemo } from 'react'; import { JSONTree } from 'react-json-tree'; import { useJsonTreeTheme } from 'src/hooks/useJsonTreeTheme'; import Button from '../Button'; @@ -46,6 +46,10 @@ import ModalTrigger from '../ModalTrigger'; export function safeJsonObjectParse( data: unknown, ): null | unknown[] | Record<string, unknown> { + if (typeof data === 'object') { + return data as null | unknown[] | Record<string, unknown>; + } + // First perform a cheap proxy to avoid calling JSON.parse on data that is clearly not a // JSON object or array if ( @@ -78,7 +82,7 @@ function renderBigIntStrToNumber(value: string | number) { return <>{convertBigIntStrToNumber(value)}</>; } -type CellDataType = string | number | null; +type CellDataType = string | number | null | object; export interface Props { modalTitle: string; @@ -88,6 +92,11 @@ export interface Props { export const JsonModal: FC<Props> = ({ modalTitle, jsonObject, jsonValue }) => { const jsonTreeTheme = useJsonTreeTheme(); + const content = useMemo( + () => + typeof jsonValue === 'object' ? JSON.stringify(jsonValue) : jsonValue, + [jsonValue], + ); return ( <ModalTrigger @@ -100,11 +109,11 @@ export const JsonModal: FC<Props> = ({ modalTitle, jsonObject, jsonValue }) => { } modalFooter={ <Button> - <CopyToClipboard shouldShowText={false} text={jsonValue} /> + <CopyToClipboard shouldShowText={false} text={content} /> </Button> } modalTitle={modalTitle} - triggerNode={<>{jsonValue}</>} + triggerNode={<>{content}</>} /> ); }; diff --git a/superset-frontend/src/components/Label/reusable/DatasetTypeLabel.tsx b/superset-frontend/src/components/Label/reusable/DatasetTypeLabel.tsx index e1f7fe75c7cd07ab476082a654171b6a4ddeea6e..e779f379998b648421b952c9735def38d33f68ee 100644 --- a/superset-frontend/src/components/Label/reusable/DatasetTypeLabel.tsx +++ b/superset-frontend/src/components/Label/reusable/DatasetTypeLabel.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Label from 'src/components/Label'; import { t, useTheme } from '@superset-ui/core'; diff --git a/superset-frontend/src/components/Label/reusable/PublishedLabel.tsx b/superset-frontend/src/components/Label/reusable/PublishedLabel.tsx index ddff92e010713276d9010173a0a5fdff0eb87b0e..b25a3a64a1a3647368cf12e126f40e0d90e37062 100644 --- a/superset-frontend/src/components/Label/reusable/PublishedLabel.tsx +++ b/superset-frontend/src/components/Label/reusable/PublishedLabel.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Label from 'src/components/Label'; import { t, useTheme } from '@superset-ui/core'; diff --git a/superset-frontend/src/components/LastUpdated/index.tsx b/superset-frontend/src/components/LastUpdated/index.tsx index f108d3368051681e17feec7c00e4439833991a34..91bee1997d90206b657a441d0eb2d101108baa4e 100644 --- a/superset-frontend/src/components/LastUpdated/index.tsx +++ b/superset-frontend/src/components/LastUpdated/index.tsx @@ -25,7 +25,7 @@ import { import { extendedDayjs } from 'src/utils/dates'; import { t, styled, css } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import dayjs from 'dayjs'; const REFRESH_INTERVAL = 60000; // every minute diff --git a/superset-frontend/src/components/ListView/ActionsBar.tsx b/superset-frontend/src/components/ListView/ActionsBar.tsx index d9d617013351c5f6d185ca2f2f2cbe7254798300..e04f5475af0ec2e6795d764d7abad0a4d9ffa269 100644 --- a/superset-frontend/src/components/ListView/ActionsBar.tsx +++ b/superset-frontend/src/components/ListView/ActionsBar.tsx @@ -19,7 +19,7 @@ import { ReactElement } from 'react'; import { styled } from '@superset-ui/core'; import { Tooltip, TooltipPlacement } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons, IconNameType } from 'src/components/Icons'; export type ActionProps = { label: string; @@ -56,7 +56,7 @@ export default function ActionsBar({ actions }: ActionsBarProps) { return ( <StyledActions className="actions"> {actions.map((action, index) => { - const ActionIcon = Icons[action.icon]; + const ActionIcon = Icons[action.icon as IconNameType]; if (action.tooltip) { return ( <Tooltip diff --git a/superset-frontend/src/components/ListView/Filters/Search.tsx b/superset-frontend/src/components/ListView/Filters/Search.tsx index da0243310af3dbd664569a479cf7b8125882f71d..313467ab96c2a1b74060d7cd846344ef91961e0e 100644 --- a/superset-frontend/src/components/ListView/Filters/Search.tsx +++ b/superset-frontend/src/components/ListView/Filters/Search.tsx @@ -25,7 +25,7 @@ import { } from 'react'; import { t, styled, useTheme, css } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { Input as AntdInput } from 'src/components/Input'; import { SELECT_WIDTH } from 'src/components/ListView/utils'; import { FormLabel } from 'src/components/Form'; diff --git a/superset-frontend/src/components/ListView/Filters/Select.tsx b/superset-frontend/src/components/ListView/Filters/Select.tsx index edbc5ad452b59231d00adabf22601d4e20f07410..02cd898975938118d6c17d02e2af1ff85296cb76 100644 --- a/superset-frontend/src/components/ListView/Filters/Select.tsx +++ b/superset-frontend/src/components/ListView/Filters/Select.tsx @@ -37,6 +37,7 @@ interface SelectFilterProps extends BaseFilter { onSelect: (selected: SelectOption | undefined, isClear?: boolean) => void; paginate?: boolean; selects: Filter['selects']; + loading?: boolean; } function SelectFilter( @@ -47,6 +48,7 @@ function SelectFilter( initialValue, onSelect, selects = [], + loading = false, }: SelectFilterProps, ref: RefObject<FilterHandler>, ) { @@ -115,6 +117,7 @@ function SelectFilter( placeholder={t('Select or type a value')} showSearch value={selectedOption} + loading={loading} /> )} </FilterContainer> diff --git a/superset-frontend/src/components/ListView/Filters/index.tsx b/superset-frontend/src/components/ListView/Filters/index.tsx index 5733151180ea776fb17dd7a047e1d445fbb053e8..8f64b67d051f6dc6f43892a353f7dd2852b12212 100644 --- a/superset-frontend/src/components/ListView/Filters/index.tsx +++ b/superset-frontend/src/components/ListView/Filters/index.tsx @@ -75,6 +75,7 @@ function UIFilters( selects, toolTipDescription, onFilterUpdate, + loading, }, index, ) => { @@ -103,6 +104,7 @@ function UIFilters( }} paginate={paginate} selects={selects} + loading={loading ?? false} /> ); } diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx index 3249d0b0a7e3dd1791207ecc535ccbf0d44de976..13ea7f91133774767f401db46eaa1c10e276ce83 100644 --- a/superset-frontend/src/components/ListView/ListView.tsx +++ b/superset-frontend/src/components/ListView/ListView.tsx @@ -21,7 +21,7 @@ import { useCallback, useEffect, useRef, useState, ReactNode } from 'react'; import Alert from 'src/components/Alert'; import cx from 'classnames'; import Button from 'src/components/Button'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; import Pagination from 'src/components/Pagination'; import TableCollection from 'src/components/TableCollection'; diff --git a/superset-frontend/src/components/ListView/types.ts b/superset-frontend/src/components/ListView/types.ts index 90de4eab18e765266186e7cc7c7e268e9479cf24..5b498070acffbd131467ee1d5f0bb8480083b796 100644 --- a/superset-frontend/src/components/ListView/types.ts +++ b/superset-frontend/src/components/ListView/types.ts @@ -59,6 +59,7 @@ export interface Filter { pageSize: number, ) => Promise<{ data: SelectOption[]; totalCount: number }>; paginate?: boolean; + loading?: boolean; } export type Filters = Filter[]; diff --git a/superset-frontend/src/components/ListViewCard/ListViewCard.stories.tsx b/superset-frontend/src/components/ListViewCard/ListViewCard.stories.tsx index 61ff54d60ba4b02f4507e97a0b3b545aa32322cc..d3f81b23f0107c4c0842c4c2c5c269e208b9074b 100644 --- a/superset-frontend/src/components/ListViewCard/ListViewCard.stories.tsx +++ b/superset-frontend/src/components/ListViewCard/ListViewCard.stories.tsx @@ -19,7 +19,7 @@ import { action } from '@storybook/addon-actions'; import { Dropdown } from 'src/components/Dropdown'; import { Menu } from 'src/components/Menu'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import FaveStar from 'src/components/FaveStar'; import ListViewCard from '.'; diff --git a/superset-frontend/src/components/MessageToasts/Toast.tsx b/superset-frontend/src/components/MessageToasts/Toast.tsx index 96d906342968d4296a0cb183c8195751b42da30e..6b67846c221fca459044a974568b411c4a764a75 100644 --- a/superset-frontend/src/components/MessageToasts/Toast.tsx +++ b/superset-frontend/src/components/MessageToasts/Toast.tsx @@ -20,7 +20,7 @@ import { styled, css, SupersetTheme } from '@superset-ui/core'; import cx from 'classnames'; import { Interweave } from 'interweave'; import { useCallback, useEffect, useRef, useState } from 'react'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { ToastType, ToastMeta } from './types'; const ToastContainer = styled.div` diff --git a/superset-frontend/src/components/MetadataBar/ContentConfig.tsx b/superset-frontend/src/components/MetadataBar/ContentConfig.tsx index 8a349637dbacf479ab6f76fa541bb266928461de..9ea1bd740fa19bc1fa1f18bebc7e4b0880291900 100644 --- a/superset-frontend/src/components/MetadataBar/ContentConfig.tsx +++ b/superset-frontend/src/components/MetadataBar/ContentConfig.tsx @@ -17,7 +17,7 @@ * under the License. */ import { ensureIsArray, styled, t } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { ContentType, MetadataType } from '.'; const Header = styled.div` diff --git a/superset-frontend/src/components/Modal/FormModal.test.tsx b/superset-frontend/src/components/Modal/FormModal.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c061e83acd592d5bac78d11928dd0a2578dd83b9 --- /dev/null +++ b/superset-frontend/src/components/Modal/FormModal.test.tsx @@ -0,0 +1,115 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + render, + fireEvent, + screen, + userEvent, + waitFor, +} from 'spec/helpers/testing-library'; +import FormModal, { FormModalProps } from 'src/components/Modal/FormModal'; +import { FormItem } from 'src/components/Form'; +import { Input } from 'src/components/Input'; + +describe('FormModal Component', () => { + const children = ( + <> + <FormItem + name="name" + label="Name" + rules={[{ required: true, message: 'Name is required' }]} + > + <Input placeholder="Enter your name" aria-label="Name" /> + </FormItem> + <FormItem name="email" label="Email"> + <Input placeholder="Enter your email" aria-label="Email" /> + </FormItem> + </> + ); + + const mockedProps: FormModalProps = { + show: true, + onHide: jest.fn(), + title: 'Test Form Modal', + onSave: jest.fn(), + formSubmitHandler: jest.fn().mockResolvedValue(undefined), + initialValues: { name: '', email: '' }, + requiredFields: ['name'], + children, + }; + + const renderComponent = () => render(<FormModal {...mockedProps} />); + + it('should render the modal with two input fields', () => { + renderComponent(); + + expect(screen.getByLabelText('Name')).toBeInTheDocument(); + expect(screen.getByLabelText('Email')).toBeInTheDocument(); + }); + + it('should disable Save button when required fields are empty', async () => { + renderComponent(); + + const saveButton = screen.getByTestId('form-modal-save-button'); + expect(saveButton).toBeDisabled(); + }); + + it('should enable Save button only when the required field is filled', async () => { + renderComponent(); + + const nameInput = screen.getByPlaceholderText('Enter your name'); + userEvent.type(nameInput, 'Jane Doe'); + + await waitFor(() => { + expect(screen.getByTestId('form-modal-save-button')).toBeEnabled(); + }); + }); + + it('should keep Save button disabled when only the optional field is filled', async () => { + renderComponent(); + + const emailInput = screen.getByPlaceholderText('Enter your email'); + userEvent.type(emailInput, 'test@example.com'); + + await waitFor(() => { + expect(screen.getByTestId('form-modal-save-button')).toBeDisabled(); + }); + }); + + it('should call formSubmitHandler with correct values when submitted', async () => { + renderComponent(); + + userEvent.type(screen.getByPlaceholderText('Enter your name'), 'Jane Doe'); + userEvent.type( + screen.getByPlaceholderText('Enter your email'), + 'test@example.com', + ); + + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(mockedProps.formSubmitHandler).toHaveBeenCalledWith({ + name: 'Jane Doe', + email: 'test@example.com', + }); + expect(mockedProps.onSave).toHaveBeenCalled(); + }); + }); +}); diff --git a/superset-frontend/src/components/Modal/FormModal.tsx b/superset-frontend/src/components/Modal/FormModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6141f7806e69f775b854d17d4b44322b54666d93 --- /dev/null +++ b/superset-frontend/src/components/Modal/FormModal.tsx @@ -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 Modal, { ModalProps } from 'src/components/Modal'; +import Button from 'src/components/Button'; +import { Form } from 'src/components/Form'; +import { useState, useCallback } from 'react'; +import { t } from '@superset-ui/core'; + +export interface FormModalProps extends ModalProps { + initialValues: Object; + formSubmitHandler: (values: Object) => Promise<void>; + onSave: () => void; + requiredFields: string[]; +} + +function FormModal({ + show, + onHide, + title, + onSave, + children, + initialValues = {}, + formSubmitHandler, + bodyStyle = {}, + requiredFields = [], +}: FormModalProps) { + const [form] = Form.useForm(); + const [isSaving, setIsSaving] = useState(false); + const resetForm = useCallback(() => { + form.resetFields(); + setIsSaving(false); + }, [form]); + const [submitDisabled, setSubmitDisabled] = useState(true); + + const handleClose = useCallback(() => { + resetForm(); + onHide(); + }, [onHide, resetForm]); + + const handleSave = useCallback(() => { + resetForm(); + onSave(); + }, [onSave, resetForm]); + + const handleFormSubmit = useCallback( + async values => { + try { + setIsSaving(true); + await formSubmitHandler(values); + handleSave(); + } catch (err) { + console.error(err); + } finally { + setIsSaving(false); + } + }, + [formSubmitHandler, handleSave], + ); + + const onFormChange = () => { + const hasErrors = form.getFieldsError().some(({ errors }) => errors.length); + + const values = form.getFieldsValue(); + const hasEmptyRequired = requiredFields.some(field => !values[field]); + + setSubmitDisabled(hasErrors || hasEmptyRequired); + }; + + return ( + <Modal + show={show} + title={title} + onHide={handleClose} + bodyStyle={bodyStyle} + footer={ + <> + <Button + buttonStyle="secondary" + data-test="modal-cancel-button" + onClick={handleClose} + > + {t('Cancel')} + </Button> + <Button + buttonStyle="primary" + htmlType="submit" + onClick={() => form.submit()} + data-test="form-modal-save-button" + disabled={isSaving || submitDisabled} + > + {isSaving ? t('Saving...') : t('Save')} + </Button> + </> + } + > + <Form + form={form} + layout="vertical" + onFinish={handleFormSubmit} + initialValues={initialValues} + onValuesChange={onFormChange} + onFieldsChange={onFormChange} + > + {typeof children === 'function' ? children(form) : children} + </Form> + </Modal> + ); +} + +export default FormModal; diff --git a/superset-frontend/src/components/PageHeaderWithActions/index.tsx b/superset-frontend/src/components/PageHeaderWithActions/index.tsx index 8880ab00718ee1e9d7ac143b34bbbc6576e3491c..62c27ad8110fd12eeb5e4671ef1f52cfa8d2398f 100644 --- a/superset-frontend/src/components/PageHeaderWithActions/index.tsx +++ b/superset-frontend/src/components/PageHeaderWithActions/index.tsx @@ -20,13 +20,13 @@ import { ReactNode, ReactElement } from 'react'; import { css, SupersetTheme, t, useTheme } from '@superset-ui/core'; import { Dropdown, DropdownProps } from 'src/components/Dropdown'; import { TooltipPlacement } from 'src/components/Tooltip'; +import { Icons } from 'src/components/Icons'; import { DynamicEditableTitle, DynamicEditableTitleProps, } from '../DynamicEditableTitle'; import CertifiedBadge, { CertifiedBadgeProps } from '../CertifiedBadge'; import FaveStar, { FaveStarProps } from '../FaveStar'; -import Icons from '../Icons'; import Button from '../Button'; export const menuTriggerStyles = (theme: SupersetTheme) => css` diff --git a/superset-frontend/src/components/Popover/Popover.test.tsx b/superset-frontend/src/components/Popover/Popover.test.tsx index 6dcfe2c544d72c01ea8373d2942811348507ecca..92bf0576b64c5d3f8763017e1191493a7cdc337f 100644 --- a/superset-frontend/src/components/Popover/Popover.test.tsx +++ b/superset-frontend/src/components/Popover/Popover.test.tsx @@ -23,7 +23,7 @@ import { waitFor, } from 'spec/helpers/testing-library'; import { supersetTheme } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Button from 'src/components/Button'; import Popover from 'src/components/Popover'; diff --git a/superset-frontend/src/components/PopoverDropdown/index.tsx b/superset-frontend/src/components/PopoverDropdown/index.tsx index b781ff6ce47d611222f996e70d0b103e87a3edb3..2c3c4d24070840856d358ce13913f9cb276859bb 100644 --- a/superset-frontend/src/components/PopoverDropdown/index.tsx +++ b/superset-frontend/src/components/PopoverDropdown/index.tsx @@ -21,7 +21,7 @@ import cx from 'classnames'; import { styled, useTheme } from '@superset-ui/core'; import { Dropdown } from 'src/components/Dropdown'; import { Menu } from 'src/components/Menu'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; export interface OptionProps { value: string; diff --git a/superset-frontend/src/components/PopoverSection/index.tsx b/superset-frontend/src/components/PopoverSection/index.tsx index 9a687357d8c77863a585ede68fff85166d34b4e5..bbff815388a1a612a4f31c78c5d73e68f42adc22 100644 --- a/superset-frontend/src/components/PopoverSection/index.tsx +++ b/superset-frontend/src/components/PopoverSection/index.tsx @@ -19,7 +19,7 @@ import { MouseEventHandler, ReactNode } from 'react'; import { css, useTheme } from '@superset-ui/core'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; export interface PopoverSectionProps { title: string; diff --git a/superset-frontend/src/components/Radio/Radio.stories.tsx b/superset-frontend/src/components/Radio/Radio.stories.tsx index 5155c7d23ca19a14f292221daee730951cc06fef..90b5c9b5a25d4e228784cda0a1abc6240a62b4c5 100644 --- a/superset-frontend/src/components/Radio/Radio.stories.tsx +++ b/superset-frontend/src/components/Radio/Radio.stories.tsx @@ -17,7 +17,7 @@ * under the License. */ import { Space } from 'src/components/Space'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { css } from '@superset-ui/core'; import { Radio, RadioProps, RadioGroupWrapperProps } from './index'; @@ -121,19 +121,6 @@ RadioGroupWithOptionsStory.args = { </Space> ), }, - { - value: 2, - label: ( - <Space align="center" direction="vertical"> - <Icons.DotChartOutlined - css={css` - font-size: 18; - `} - /> - DotChart - </Space> - ), - }, { value: 3, label: ( diff --git a/superset-frontend/src/components/RefreshLabel/index.tsx b/superset-frontend/src/components/RefreshLabel/index.tsx index 1fb7771f4f70acdad49494a7b0d446a4c7b61e18..153b6225b79f834d84969f51cd6d40112a6c2bc0 100644 --- a/superset-frontend/src/components/RefreshLabel/index.tsx +++ b/superset-frontend/src/components/RefreshLabel/index.tsx @@ -19,7 +19,7 @@ import { MouseEventHandler, forwardRef } from 'react'; import { SupersetTheme } from '@superset-ui/core'; import { Tooltip } from 'src/components/Tooltip'; -import Icons, { IconType } from 'src/components/Icons'; +import { Icons, IconType } from 'src/components/Icons'; export interface RefreshLabelProps { onClick: MouseEventHandler<HTMLSpanElement>; diff --git a/superset-frontend/src/components/Select/AsyncSelect.tsx b/superset-frontend/src/components/Select/AsyncSelect.tsx index 29c6fd1283ec8a59a593b7921100fcd56c25925e..4615f93418620499ded30c3ad2fed0c6bfbbfe4a 100644 --- a/superset-frontend/src/components/Select/AsyncSelect.tsx +++ b/superset-frontend/src/components/Select/AsyncSelect.tsx @@ -40,7 +40,7 @@ import { // eslint-disable-next-line no-restricted-imports import { LabeledValue as AntdLabeledValue } from 'antd/lib/select'; // TODO: Remove antd import { debounce, isEqual, uniq } from 'lodash'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { FAST_DEBOUNCE, SLOW_DEBOUNCE } from 'src/constants'; import { getValue, diff --git a/superset-frontend/src/components/Select/styles.tsx b/superset-frontend/src/components/Select/styles.tsx index 726f6af628e0503975f5a3721341a75e086d02e8..5ccc4a5fa5efe47018a86a5b8c01dc4da9153a06 100644 --- a/superset-frontend/src/components/Select/styles.tsx +++ b/superset-frontend/src/components/Select/styles.tsx @@ -17,7 +17,7 @@ * under the License. */ import { styled } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; // eslint-disable-next-line no-restricted-imports import { Spin, Tag } from 'antd'; // TODO: Remove antd // eslint-disable-next-line no-restricted-imports diff --git a/superset-frontend/src/components/Select/utils.tsx b/superset-frontend/src/components/Select/utils.tsx index 0c21ec228ef38994abaa4c72a4942a201b4f5213..7025b7951ad73e263ff5d6660efc68f14a6a362e 100644 --- a/superset-frontend/src/components/Select/utils.tsx +++ b/superset-frontend/src/components/Select/utils.tsx @@ -20,7 +20,7 @@ import { ensureIsArray, t } from '@superset-ui/core'; // eslint-disable-next-line no-restricted-imports import AntdSelect, { LabeledValue as AntdLabeledValue } from 'antd/lib/select'; // TODO: Remove antd import { ReactElement, RefObject } from 'react'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { StyledHelperText, StyledLoadingText, StyledSpin } from './styles'; import { LabeledValue, RawValue, SelectOptionsType, V } from './types'; diff --git a/superset-frontend/src/components/Table/header-renderers/HeaderWithRadioGroup.tsx b/superset-frontend/src/components/Table/header-renderers/HeaderWithRadioGroup.tsx index fe43fd8954ca3a511c26661d68f5386a139929ad..c6e9562301cdb6d2bdd7817d75ecfcee0258eae7 100644 --- a/superset-frontend/src/components/Table/header-renderers/HeaderWithRadioGroup.tsx +++ b/superset-frontend/src/components/Table/header-renderers/HeaderWithRadioGroup.tsx @@ -19,7 +19,7 @@ import { useState } from 'react'; import { css, useTheme } from '@superset-ui/core'; import { Radio } from 'src/components/Radio'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Popover from 'src/components/Popover'; export interface HeaderWithRadioGroupProps { diff --git a/superset-frontend/src/components/TableCollection/index.tsx b/superset-frontend/src/components/TableCollection/index.tsx index 5644db53b728d0364b46cdce3444761aa92b4e9e..d48c7a78edc7969bbdae0d04e58db8c024573fc8 100644 --- a/superset-frontend/src/components/TableCollection/index.tsx +++ b/superset-frontend/src/components/TableCollection/index.tsx @@ -20,7 +20,7 @@ import { memo } from 'react'; import cx from 'classnames'; import { TableInstance } from 'react-table'; import { styled } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; interface TableCollectionProps { getTableProps: (userProps?: any) => any; diff --git a/superset-frontend/src/components/TableSelector/index.tsx b/superset-frontend/src/components/TableSelector/index.tsx index a46be828229e9282cd9d3a4045173be62d1c9a81..f0e7798861321f0dab5037a99d50396f701c78ef 100644 --- a/superset-frontend/src/components/TableSelector/index.tsx +++ b/superset-frontend/src/components/TableSelector/index.tsx @@ -34,7 +34,7 @@ import { } from '@superset-ui/core'; import { Select } from 'src/components'; import { FormLabel } from 'src/components/Form'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import DatabaseSelector, { DatabaseObject, } from 'src/components/DatabaseSelector'; diff --git a/superset-frontend/src/components/Tabs/Tabs.tsx b/superset-frontend/src/components/Tabs/Tabs.tsx index c12712976bea616477e6b471a2af6c41ca6d255c..ac05e0c3aab9106874bb2ff3e2140bcfb4653729 100644 --- a/superset-frontend/src/components/Tabs/Tabs.tsx +++ b/superset-frontend/src/components/Tabs/Tabs.tsx @@ -19,7 +19,7 @@ import { css, styled } from '@superset-ui/core'; // eslint-disable-next-line no-restricted-imports import AntdTabs, { TabsProps as AntdTabsProps } from 'antd/lib/tabs'; // TODO: Remove antd -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; export interface TabsProps extends AntdTabsProps { fullWidth?: boolean; diff --git a/superset-frontend/src/components/Tags/Tag.tsx b/superset-frontend/src/components/Tags/Tag.tsx index 063000b0fbaa34e9b51813cc6582be06a2788acf..6bb3223c6752cdd9bc01418327c5b6cd815c014a 100644 --- a/superset-frontend/src/components/Tags/Tag.tsx +++ b/superset-frontend/src/components/Tags/Tag.tsx @@ -22,7 +22,7 @@ import TagType from 'src/types/TagType'; import { Tag as AntdTag } from 'antd-v5'; import { useMemo } from 'react'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; const StyledTag = styled(AntdTag)` ${({ theme }) => ` diff --git a/superset-frontend/src/components/Timer/index.tsx b/superset-frontend/src/components/Timer/index.tsx index 00d7c1a75143b641434991209dcd65cc5d3766ff..a9136657a4602dc3d6f9fc07a3d4cd8c4acb95ed 100644 --- a/superset-frontend/src/components/Timer/index.tsx +++ b/superset-frontend/src/components/Timer/index.tsx @@ -19,7 +19,7 @@ import { useEffect, useRef, useState } from 'react'; import { styled, useTheme } from '@superset-ui/core'; import Label, { Type } from 'src/components/Label'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { now, fDuration } from 'src/utils/dates'; diff --git a/superset-frontend/src/components/Tooltip/Tooltip.test.tsx b/superset-frontend/src/components/Tooltip/Tooltip.test.tsx index 95f66e6e743d8f330a906198e725cfa6e72839da..8b2d418894b5cbf53ffe437c563452c09c36e790 100644 --- a/superset-frontend/src/components/Tooltip/Tooltip.test.tsx +++ b/superset-frontend/src/components/Tooltip/Tooltip.test.tsx @@ -19,7 +19,7 @@ import { render, screen, userEvent } from 'spec/helpers/testing-library'; import { supersetTheme } from '@superset-ui/core'; import Button from 'src/components/Button'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { Tooltip } from '.'; test('starts hidden with default props', () => { diff --git a/superset-frontend/src/components/UiConfigContext/index.tsx b/superset-frontend/src/components/UiConfigContext/index.tsx index 99c0a506a5abf625c76dc4158a6ac1470548930b..7843967669981f46b112bc6af1b15f5b536fb541 100644 --- a/superset-frontend/src/components/UiConfigContext/index.tsx +++ b/superset-frontend/src/components/UiConfigContext/index.tsx @@ -26,6 +26,8 @@ interface UiConfigType { hideTab: boolean; hideNav: boolean; hideChartControls: boolean; + // Only used in superset-embedded-sdk to emit data masks to the parent window + emitDataMasks: boolean; } interface EmbeddedUiConfigProviderProps { children: JSX.Element; @@ -36,6 +38,7 @@ export const UiConfigContext = createContext<UiConfigType>({ hideTab: false, hideNav: false, hideChartControls: false, + emitDataMasks: false, }); export const useUiConfig = () => useContext(UiConfigContext); @@ -49,6 +52,7 @@ export const EmbeddedUiConfigProvider: FC<EmbeddedUiConfigProviderProps> = ({ hideTab: (config & 2) !== 0, hideNav: (config & 4) !== 0, hideChartControls: (config & 8) !== 0, + emitDataMasks: (config & 16) !== 0, }); return ( diff --git a/superset-frontend/src/components/WarningIconWithTooltip/index.tsx b/superset-frontend/src/components/WarningIconWithTooltip/index.tsx index 94638d49234aefee3b0dfb27085b3635195062db..543b3fc29a8dc45c5ce163453e0e61cfe12e972e 100644 --- a/superset-frontend/src/components/WarningIconWithTooltip/index.tsx +++ b/superset-frontend/src/components/WarningIconWithTooltip/index.tsx @@ -17,7 +17,7 @@ * under the License. */ import { useTheme, SafeMarkdown } from '@superset-ui/core'; -import Icons, { IconType } from 'src/components/Icons'; +import { Icons, IconType } from 'src/components/Icons'; import { Tooltip } from 'src/components/Tooltip'; export interface WarningIconWithTooltipProps { diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx index 8a83e995475b21aa000cb5ca5806dcefdb23b06c..9963369745c5c2924e14f5e6cf824a2ffcba969e 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx @@ -32,6 +32,7 @@ import { import { storeWithState } from 'spec/fixtures/mockStore'; import mockState from 'spec/fixtures/mockState'; import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants'; +import * as useNativeFiltersModule from './state'; fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {}); fetchMock.put('glob:*/api/v1/dashboard/*', {}); @@ -262,4 +263,45 @@ describe('DashboardBuilder', () => { const filterbar = getByTestId('dashboard-filters-panel'); expect(filterbar).toHaveStyleRule('width', `${expectedValue}px`); }); + + it('should not render the filter bar when nativeFiltersEnabled is false', () => { + jest.spyOn(useNativeFiltersModule, 'useNativeFilters').mockReturnValue({ + showDashboard: true, + missingInitialFilters: [], + dashboardFiltersOpen: true, + toggleDashboardFiltersOpen: jest.fn(), + nativeFiltersEnabled: false, + }); + const { queryByTestId } = setup(); + + expect(queryByTestId('dashboard-filters-panel')).not.toBeInTheDocument(); + }); + + it('should render the filter bar when nativeFiltersEnabled is true and not in edit mode', () => { + jest.spyOn(useNativeFiltersModule, 'useNativeFilters').mockReturnValue({ + showDashboard: true, + missingInitialFilters: [], + dashboardFiltersOpen: true, + toggleDashboardFiltersOpen: jest.fn(), + nativeFiltersEnabled: true, + }); + const { queryByTestId } = setup(); + + expect(queryByTestId('dashboard-filters-panel')).toBeInTheDocument(); + }); + + it('should not render the filter bar when in edit mode even if nativeFiltersEnabled is true', () => { + jest.spyOn(useNativeFiltersModule, 'useNativeFilters').mockReturnValue({ + showDashboard: true, + missingInitialFilters: [], + dashboardFiltersOpen: true, + toggleDashboardFiltersOpen: jest.fn(), + nativeFiltersEnabled: true, + }); + const { queryByTestId } = setup({ + dashboardState: { ...mockState.dashboardState, editMode: true }, + }); + + expect(queryByTestId('dashboard-filters-panel')).not.toBeInTheDocument(); + }); }); diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx index c1f8cfe76d77ce7757d3f90046c0b4729601d9ca..7694140aa214ea11810368ca17cfa0427771b930 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx @@ -32,7 +32,7 @@ import { useDispatch, useSelector } from 'react-redux'; import ErrorBoundary from 'src/components/ErrorBoundary'; import BuilderComponentPane from 'src/dashboard/components/BuilderComponentPane'; import DashboardHeader from 'src/dashboard/components/Header'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import IconButton from 'src/dashboard/components/IconButton'; import { Droppable } from 'src/dashboard/components/dnd/DragDroppable'; import DashboardComponent from 'src/dashboard/containers/DashboardComponent'; @@ -467,7 +467,7 @@ const DashboardBuilder = () => { ELEMENT_ON_SCREEN_OPTIONS, ); - const showFilterBar = !editMode; + const showFilterBar = !editMode && nativeFiltersEnabled; const offset = FILTER_BAR_HEADER_HEIGHT + diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts index ec1cc0bc1f0f81e4a527582a85c16b53527d0497..2c45a799f147a340ef9c48ce9550b87d7eb9357e 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts @@ -29,6 +29,9 @@ import { // eslint-disable-next-line import/prefer-default-export export const useNativeFilters = () => { const [isInitialized, setIsInitialized] = useState(false); + const showNativeFilters = useSelector<RootState, boolean>( + state => getUrlParam(URL_PARAMS.showFilters) ?? true, + ); const canEdit = useSelector<RootState, boolean>( ({ dashboardInfo }) => dashboardInfo.dash_edit_perm, ); @@ -41,7 +44,7 @@ export const useNativeFilters = () => { ); const nativeFiltersEnabled = - canEdit || (!canEdit && filterValues.length !== 0); + showNativeFilters && (canEdit || (!canEdit && filterValues.length !== 0)); const requiredFirstFilter = useMemo( () => filterValues.filter(filter => filter.requiredFirst), diff --git a/superset-frontend/src/dashboard/components/DashboardGrid.jsx b/superset-frontend/src/dashboard/components/DashboardGrid.jsx index 46167ae2c85890a42e9bae5aed49a93c44f70049..3ab498145dd51c25115c69c5cba9ee5b50fe0e3f 100644 --- a/superset-frontend/src/dashboard/components/DashboardGrid.jsx +++ b/superset-frontend/src/dashboard/components/DashboardGrid.jsx @@ -22,7 +22,7 @@ import PropTypes from 'prop-types'; import classNames from 'classnames'; import { addAlpha, css, styled, t } from '@superset-ui/core'; import { EmptyState } from 'src/components/EmptyState'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { componentShape } from '../util/propShapes'; import DashboardComponent from '../containers/DashboardComponent'; import { Droppable } from './dnd/DragDroppable'; diff --git a/superset-frontend/src/dashboard/components/DeleteComponentButton.tsx b/superset-frontend/src/dashboard/components/DeleteComponentButton.tsx index 9c1231c12745b41702d802306222ee887d15adfc..221d544493c510325281f275f6a7abc25675c980 100644 --- a/superset-frontend/src/dashboard/components/DeleteComponentButton.tsx +++ b/superset-frontend/src/dashboard/components/DeleteComponentButton.tsx @@ -18,7 +18,7 @@ */ import { MouseEventHandler, FC } from 'react'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import IconButton from './IconButton'; type DeleteComponentButtonProps = { diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/FilterIndicator/index.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/FilterIndicator/index.tsx index 8eb1cf4c646cc7dace88fc681ae2e0c2b9e38b49..9d9509b304e44ab777fc1f444fe922302ec8fcf6 100644 --- a/superset-frontend/src/dashboard/components/FiltersBadge/FilterIndicator/index.tsx +++ b/superset-frontend/src/dashboard/components/FiltersBadge/FilterIndicator/index.tsx @@ -19,7 +19,7 @@ import { forwardRef } from 'react'; import { css } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { getFilterValueForDisplay } from 'src/dashboard/components/nativeFilters/utils'; import { FilterValue, diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx index 5f544d69969644b21db1a53fa96fb9262cad2e4f..216dac4006973edd981e6b5f8f128962f53499c9 100644 --- a/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx +++ b/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx @@ -37,7 +37,7 @@ import { t, usePrevious, } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { setDirectPathToChild } from 'src/dashboard/actions/dashboardState'; import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems'; import Badge from 'src/components/Badge'; diff --git a/superset-frontend/src/dashboard/components/Header/Header.test.tsx b/superset-frontend/src/dashboard/components/Header/Header.test.tsx index 987f729129690ddd02595f30f70fed1591cc3f80..a901995a3892fb85d4987480bac9ca989de01a9c 100644 --- a/superset-frontend/src/dashboard/components/Header/Header.test.tsx +++ b/superset-frontend/src/dashboard/components/Header/Header.test.tsx @@ -197,6 +197,10 @@ beforeEach(() => { jest.clearAllMocks(); }); +beforeEach(() => { + window.history.pushState({}, 'Test page', '/dashboard?standalone=1'); +}); + test('should render', () => { const { container } = setup(); expect(container).toBeInTheDocument(); @@ -438,6 +442,36 @@ test('should NOT render MetadataBar when embedded', () => { ).not.toBeInTheDocument(); }); +test('should hide edit button and navbar, and show Exit fullscreen when in fullscreen mode', () => { + const fullscreenState = { + ...initialState, + dashboardState: { + ...initialState.dashboardState, + isFullscreenMode: true, + }, + }; + + setup(fullscreenState); + expect(screen.queryByTestId('edit-dashboard-button')).not.toBeInTheDocument(); + expect(screen.getByTestId('actions-trigger')).toBeInTheDocument(); + expect(screen.queryByTestId('main-navigation')).not.toBeInTheDocument(); +}); + +test('should show Exit fullscreen when in fullscreen mode', async () => { + setup(); + + fireEvent.click(screen.getByTestId('actions-trigger')); + + expect(await screen.findByText('Exit fullscreen')).toBeInTheDocument(); +}); + +test('should have fullscreen option in dropdown', async () => { + setup(); + await openActionsDropdown(); + expect(screen.getByText('Exit fullscreen')).toBeInTheDocument(); + expect(screen.queryByText('Enter fullscreen')).not.toBeInTheDocument(); +}); + test('should render MetadataBar when not in edit mode and not embedded', () => { const state = { dashboardInfo: { diff --git a/superset-frontend/src/dashboard/components/Header/index.jsx b/superset-frontend/src/dashboard/components/Header/index.jsx index 4c1cb49ab82b73f92c18307f6ad92b1af571085e..cdc587cef03681d4207764194ca014e1b04c9516 100644 --- a/superset-frontend/src/dashboard/components/Header/index.jsx +++ b/superset-frontend/src/dashboard/components/Header/index.jsx @@ -36,7 +36,7 @@ import { LOG_ACTIONS_FORCE_REFRESH_DASHBOARD, LOG_ACTIONS_TOGGLE_EDIT_DASHBOARD, } from 'src/logger/LogUtils'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { Button } from 'src/components/'; import { findPermission } from 'src/utils/findPermission'; import { Tooltip } from 'src/components/Tooltip'; diff --git a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx index 420e91e5524b69f44ccf670b49db28c51fc3f73c..ef58671faa88213f70e4b1cea2def82ba2bb4bc6 100644 --- a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx +++ b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx @@ -97,11 +97,13 @@ export const useHeaderActionsMenu = ({ showPropertiesModal(); break; case MenuKeys.ToggleFullscreen: { + const isCurrentlyStandalone = + Number(getUrlParam(URL_PARAMS.standalone)) === 1; const url = getDashboardUrl({ pathname: window.location.pathname, filters: getActiveFilters(), hash: window.location.hash, - standalone: getUrlParam(URL_PARAMS.standalone), + standalone: isCurrentlyStandalone ? null : 1, }); window.location.replace(url); break; diff --git a/superset-frontend/src/dashboard/components/SliceAdder.tsx b/superset-frontend/src/dashboard/components/SliceAdder.tsx index 5c965d9cc530ef4f5ff82b2fc85d6e565b5be178..d861c8ff32fdc21812f03dea54e30c13346b7a40 100644 --- a/superset-frontend/src/dashboard/components/SliceAdder.tsx +++ b/superset-frontend/src/dashboard/components/SliceAdder.tsx @@ -27,7 +27,7 @@ import { Input } from 'src/components/Input'; import { Select } from 'src/components'; import Loading from 'src/components/Loading'; import Button from 'src/components/Button'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { LocalStorageKeys, getItem, diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx index ab93f0452018cde3b51d0537bda3fd713fa9f87b..3de129661051cd58a342af2b6f77fd7d00fff2c7 100644 --- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx @@ -32,7 +32,7 @@ import EditableTitle from 'src/components/EditableTitle'; import SliceHeaderControls from 'src/dashboard/components/SliceHeaderControls'; import { SliceHeaderControlsProps } from 'src/dashboard/components/SliceHeaderControls/types'; import FiltersBadge from 'src/dashboard/components/FiltersBadge'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { RootState } from 'src/dashboard/types'; import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'; import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage'; diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 1c31b6752979acb40928c0fe03692bf7f45c369f..83b163927895bc5745d79d1479826e12a44b735b 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -46,7 +46,7 @@ import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems'; import downloadAsImage from 'src/utils/downloadAsImage'; import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import ModalTrigger from 'src/components/ModalTrigger'; import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal'; import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane'; diff --git a/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx b/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx index 317fa981138c6263e37fc8d13f7c67244c0a9c28..106e240d627d27e184860f696558a12fce47ca80 100644 --- a/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx +++ b/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx @@ -24,7 +24,7 @@ import { getDashboardPermalink } from 'src/utils/urlUtils'; import { useToasts } from 'src/components/MessageToasts/withToasts'; import { shallowEqual, useSelector } from 'react-redux'; import { RootState } from 'src/dashboard/types'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; export type URLShortLinkButtonProps = { dashboardId: number; diff --git a/superset-frontend/src/dashboard/components/dnd/DragHandle.tsx b/superset-frontend/src/dashboard/components/dnd/DragHandle.tsx index 0ba008a327df16c94c7712e1a46123bf204466c7..ab941a6386cc36ce88454685fd0cda2ebbef9833 100644 --- a/superset-frontend/src/dashboard/components/dnd/DragHandle.tsx +++ b/superset-frontend/src/dashboard/components/dnd/DragHandle.tsx @@ -18,7 +18,7 @@ */ import { LegacyRef } from 'react'; import { css, styled } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; interface DragHandleProps { position: 'left' | 'top'; diff --git a/superset-frontend/src/dashboard/components/filterscope/renderFilterScopeTreeNodes.jsx b/superset-frontend/src/dashboard/components/filterscope/renderFilterScopeTreeNodes.jsx index c1e7b672a4fe60ad6e6dde7c726e908a4280a063..38d720fc464ffd018be62391bd661d4fce5ade33 100644 --- a/superset-frontend/src/dashboard/components/filterscope/renderFilterScopeTreeNodes.jsx +++ b/superset-frontend/src/dashboard/components/filterscope/renderFilterScopeTreeNodes.jsx @@ -18,7 +18,7 @@ */ import cx from 'classnames'; import { styled } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { CHART_TYPE } from 'src/dashboard/util/componentTypes'; const ChartIcon = styled(Icons.BarChartOutlined)` diff --git a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx index 887f1baa73f586c0681e73b38b9971d4ae5f5977..b78b217aad974a0dbbdeb02d22c5073403098972 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx @@ -96,7 +96,7 @@ const ChartHolder = ({ const theme = useTheme(); const fullSizeStyle = css` && { - position: fixed; + position: fixed !important; z-index: 3000; left: 0; top: 0; diff --git a/superset-frontend/src/dashboard/components/gridComponents/Column.jsx b/superset-frontend/src/dashboard/components/gridComponents/Column.jsx index ed16a7d4d02bf9bde38db109bd4224b9e805d23c..612b8d5ef680369af765ad32a23f68b2ac98d7d2 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Column.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Column.jsx @@ -20,7 +20,7 @@ import { Fragment, useCallback, useState, useMemo, memo } from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; import { css, styled, t } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import DashboardComponent from 'src/dashboard/containers/DashboardComponent'; import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton'; import { diff --git a/superset-frontend/src/dashboard/components/gridComponents/Row.jsx b/superset-frontend/src/dashboard/components/gridComponents/Row.jsx index 7dca12167b07e25c6230b0632edc93ab878a17cc..a353d85c3b4e5c9c3861948c8790f9a7e526d7cf 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Row.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Row.jsx @@ -44,7 +44,7 @@ import DragHandle from 'src/dashboard/components/dnd/DragHandle'; import DashboardComponent from 'src/dashboard/containers/DashboardComponent'; import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton'; import HoverMenu from 'src/dashboard/components/menu/HoverMenu'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import IconButton from 'src/dashboard/components/IconButton'; import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown'; import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu'; diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx index a5ec5e50176479ff4a57777f53e9be870670ea51..0a699bc33eea9fe736679265a20fcb8dd429f317 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx @@ -21,7 +21,7 @@ import PropTypes from 'prop-types'; import { styled, t, usePrevious, css } from '@superset-ui/core'; import { useSelector } from 'react-redux'; import { LineEditableTabs } from 'src/components/Tabs'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { LOG_ACTIONS_SELECT_DASHBOARD_TAB } from 'src/logger/LogUtils'; import Modal from 'src/components/Modal'; import { DROP_LEFT, DROP_RIGHT } from 'src/dashboard/util/getDropPosition'; diff --git a/superset-frontend/src/dashboard/components/menu/BackgroundStyleDropdown.tsx b/superset-frontend/src/dashboard/components/menu/BackgroundStyleDropdown.tsx index 3919181ea6855d2a7bf1861ca88cd890cb525615..700c6f9e399a3ede6e52f34d1d04876380ebce52 100644 --- a/superset-frontend/src/dashboard/components/menu/BackgroundStyleDropdown.tsx +++ b/superset-frontend/src/dashboard/components/menu/BackgroundStyleDropdown.tsx @@ -35,7 +35,6 @@ interface BackgroundStyleDropdownProps { const BackgroundStyleOption = styled.div` ${({ theme }) => css` display: inline-block; - &:before { content: ''; width: 1em; @@ -44,17 +43,14 @@ const BackgroundStyleOption = styled.div` display: inline-block; vertical-align: middle; } - &.background--white { padding-left: 0; background: transparent; - &:before { background: ${theme.colors.grayscale.light5}; border: 1px solid ${theme.colors.grayscale.light2}; } } - /* Create the transparent rect icon */ &.background--transparent:before { background-image: diff --git a/superset-frontend/src/dashboard/components/menu/WithPopoverMenu.tsx b/superset-frontend/src/dashboard/components/menu/WithPopoverMenu.tsx index 0845e2e964b920c3a7cc299fb803978c724d04bc..7081445c9d06679eda43f73ee570b1ae6b016bf4 100644 --- a/superset-frontend/src/dashboard/components/menu/WithPopoverMenu.tsx +++ b/superset-frontend/src/dashboard/components/menu/WithPopoverMenu.tsx @@ -115,10 +115,12 @@ export default class WithPopoverMenu extends PureComponent< onChangeFocus: null, menuItems: [], isFocused: false, - shouldFocus: (event: any, container: ShouldFocusContainer) => - container?.contains(event.target) || - event.target.id === 'menu-item' || - event.target.parentNode?.id === 'menu-item', + shouldFocus: (event: any, container: ShouldFocusContainer) => { + if (container?.contains(event.target)) return true; + if (event.target.id === 'menu-item') return true; + if (event.target.parentNode?.id === 'menu-item') return true; + return false; + }, style: null, }; @@ -156,6 +158,9 @@ export default class WithPopoverMenu extends PureComponent< if (!this.props.editMode) { return; } + + event.stopPropagation(); + const { onChangeFocus, shouldFocus: shouldFocusFunc, diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilterTitle.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilterTitle.tsx index 714e3be34e8cba624f400f9aa45cd61a7089841a..be3797d02839278a6dc26cfe0199cb8d74b3cc12 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilterTitle.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilterTitle.tsx @@ -26,7 +26,7 @@ import { } from '@superset-ui/core'; import { Tooltip } from 'src/components/Tooltip'; import { FilterBarOrientation } from 'src/dashboard/types'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { ellipsisCss } from './styles'; const StyledCrossFilterTitle = styled.div` diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ChartsScopingListPanel.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ChartsScopingListPanel.tsx index 6ccd23a32f77688b59e9cd3e20c71fc4a222b593..85506affe0a06048d753c0488b559f60f17b795d 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ChartsScopingListPanel.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ChartsScopingListPanel.tsx @@ -27,7 +27,7 @@ import { } from 'src/dashboard/types'; import { useSelector } from 'react-redux'; import { CHART_TYPE } from 'src/dashboard/util/componentTypes'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Button from 'src/components/Button'; import { FilterTitle } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer'; import { NEW_CHART_SCOPING_ID } from './constants'; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ScopingTreePanel.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ScopingTreePanel.tsx index 997340cc6e58eb01b9dd3284c760ed8e1c44b990..bd1012caa0d2916e09fab77a95cf3275a6570d3f 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ScopingTreePanel.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ScopingTreePanel.tsx @@ -37,7 +37,7 @@ import { } from 'src/dashboard/types'; import { CHART_TYPE } from 'src/dashboard/util/componentTypes'; import { SelectOptionsType } from 'src/components/Select/types'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { Tooltip } from 'src/components/Tooltip'; import Alert from 'src/components/Alert'; import { NEW_CHART_SCOPING_ID } from './constants'; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx index 277a2bcfcb7e48daf9891074e9b3601a9099b08c..ee940f068d4b27a376e6ff13d667614b0bfe8a2b 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx @@ -26,7 +26,7 @@ import { saveFilterBarOrientation, saveCrossFiltersSetting, } from 'src/dashboard/actions/dashboardInfo'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Checkbox from 'src/components/Checkbox'; import { Dropdown } from 'src/components/Dropdown'; import { Button } from 'src/components'; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx index a1c69fcfd7b8cbc932645b3171dbd420b2bb2a72..7f1e7fded0bda43d2b87c2eb487f10c505dbab00 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx @@ -50,7 +50,7 @@ import { FilterBarOrientation, RootState } from 'src/dashboard/types'; import DropdownContainer, { Ref as DropdownContainerRef, } from 'src/components/DropdownContainer'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { useChartIds } from 'src/dashboard/util/charts/useChartIds'; import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems'; import { FiltersOutOfScopeCollapsible } from '../FiltersOutOfScopeCollapsible'; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterDivider.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterDivider.tsx index d8b86e6084e9a9105170bc7af96a568bb0813fb3..ea55624cd2aa399103dd8af51a5a9f9d7dd5682a 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterDivider.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterDivider.tsx @@ -23,7 +23,7 @@ import { useCSSTextTruncation, truncationCSS, } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { Tooltip } from 'src/components/Tooltip'; import { FilterBarOrientation } from 'src/dashboard/types'; import { FilterDividerProps } from './types'; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx index 8183d77205214629afdab7215b5b1cbf5ee0fde6..6e9a3f1c68897217816502eb000e3e4bddeb55b3 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx @@ -19,7 +19,7 @@ /* eslint-disable no-param-reassign */ import { css, styled, t } from '@superset-ui/core'; import { memo, FC } from 'react'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Button from 'src/components/Button'; import { getFilterBarTestId } from '../utils'; import FilterBarSettings from '../FilterBarSettings'; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx index 4d1f955c04edcfd92fcb69e163cf901a91d58103..417c4256484404880dcda2b13f120f8a7cdaa0ae 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx @@ -31,7 +31,7 @@ import { } from 'react'; import cx from 'classnames'; import { styled, t, useTheme } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Loading from 'src/components/Loading'; import { EmptyState } from 'src/components/EmptyState'; import { getFilterBarTestId } from './utils'; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/DependenciesRow.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/DependenciesRow.tsx index 60fa99f3c52e473ad9fc0727282d68784e8b65fe..a58ee02414bbdebf875c71bc79bc6ca72983ae90 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/DependenciesRow.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/DependenciesRow.tsx @@ -19,7 +19,7 @@ import { memo, useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { css, t, useTheme, useTruncation } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { setDirectPathToChild } from 'src/dashboard/actions/dashboardState'; import { DependencyItem, diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/NameRow.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/NameRow.tsx index 1463f918f3f4e84c910e1688da5cd7a184766bf2..0773e328cb09de6a3b5c68c08d72dbac4a0dfef1 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/NameRow.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/NameRow.tsx @@ -18,7 +18,7 @@ */ import { useSelector } from 'react-redux'; import { css, SupersetTheme, useTheme, useTruncation } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { useFilterConfigModal } from 'src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/useFilterConfigModal'; import { RootState } from 'src/dashboard/types'; import { Row, FilterName, InternalRow } from './Styles'; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/DraggableFilter.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/DraggableFilter.tsx index f16ceba6bcf723af4a819dd3972d4e2e169bcb8c..59b109dfb3a70ad0ac5fb24107747858ad1151d1 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/DraggableFilter.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/DraggableFilter.tsx @@ -25,7 +25,7 @@ import { useDrop, XYCoord, } from 'react-dnd'; -import Icons, { IconType } from 'src/components/Icons'; +import { Icons, IconType } from 'src/components/Icons'; interface TitleContainerProps { readonly isDragging: boolean; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx index 8accde195f1540845b89bc7382a2d8cb184403d9..b8bc7963f1f52b813f2477a88b90ff962293b6a4 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx @@ -19,7 +19,7 @@ import { forwardRef, ReactNode } from 'react'; import { styled, t, useTheme } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { FilterRemoval } from './types'; import DraggableFilter from './DraggableFilter'; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitlePane.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitlePane.tsx index 501ee8a95232c99688352b8efaf4e2af6f948106..2c4b4ba0c341ee099baee9121ee613f4e7b302eb 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitlePane.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitlePane.tsx @@ -20,7 +20,7 @@ import { useRef, FC } from 'react'; import { NativeFilterType, styled, t, useTheme } from '@superset-ui/core'; import { Button } from 'src/components'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import FilterTitleContainer from './FilterTitleContainer'; import { FilterRemoval } from './types'; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DependencyList.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DependencyList.tsx index c258396252d73d54722e8a71f8e68529441d19d8..74571397df4ec9787514a7696339c28698ed7963 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DependencyList.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DependencyList.tsx @@ -18,7 +18,7 @@ */ import { useState } from 'react'; import { styled, t } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { Select } from 'src/components'; import { CollapsibleControl } from './CollapsibleControl'; import { INPUT_WIDTH } from './constants'; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/ScopingTree.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/ScopingTree.tsx index 9921e6e8e9799165e1b625e8b8dc59b383a53779..6ad9e5e4f0c5c3c12790f560b180138a745d0e80 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/ScopingTree.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/ScopingTree.tsx @@ -22,7 +22,7 @@ import { NativeFilterScope } from '@superset-ui/core'; import { Tree } from 'src/components'; import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { useFilterScopeTree } from './state'; import { findFilterScope, getTreeCheckedItems } from './utils'; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx index 8a0851f95e88ff970250c71d61fdbfcf9896fae2..7ef4e6bacbfe8bec162d4bac37f1b05145c08825 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx @@ -64,7 +64,7 @@ import Collapse from 'src/components/Collapse'; import BasicErrorAlert from 'src/components/ErrorMessage/BasicErrorAlert'; import ErrorMessageWithStackTrace from 'src/components/ErrorMessage/ErrorMessageWithStackTrace'; import { FormItem } from 'src/components/Form'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Loading from 'src/components/Loading'; import { addDangerToast } from 'src/components/MessageToasts/actions'; import { Radio } from 'src/components/Radio'; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.tsx index 0add3cd65f194e5202561cac60d5b6f2e0e37fdd..cc06eb08335e84a151acd119017a4bbade47e216 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.tsx @@ -30,7 +30,7 @@ import { } from '@superset-ui/core'; import { useDispatch } from 'react-redux'; import { AntdForm } from 'src/components'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import ErrorBoundary from 'src/components/ErrorBoundary'; import { StyledModal } from 'src/components/Modal'; import { testWithId } from 'src/utils/testUtils'; diff --git a/superset-frontend/src/embedded/api.tsx b/superset-frontend/src/embedded/api.tsx index 9d37daf2e01bc92d6f43b3e5bfdf1aeefe88201b..2665916758b0c2a1cff0ee1dddcd927442a245a7 100644 --- a/superset-frontend/src/embedded/api.tsx +++ b/superset-frontend/src/embedded/api.tsx @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { DataMaskStateWithId } from '@superset-ui/core'; import getBootstrapData from 'src/utils/getBootstrapData'; import { store } from '../views/store'; import { getDashboardPermalink as getDashboardPermalinkUtil } from '../utils/urlUtils'; @@ -31,6 +32,7 @@ type EmbeddedSupersetApi = { getScrollSize: () => Size; getDashboardPermalink: ({ anchor }: { anchor: string }) => Promise<string>; getActiveTabs: () => string[]; + getDataMask: () => DataMaskStateWithId; }; const getScrollSize = (): Size => ({ @@ -61,8 +63,11 @@ const getDashboardPermalink = async ({ const getActiveTabs = () => store?.getState()?.dashboardState?.activeTabs || []; +const getDataMask = () => store?.getState()?.dataMask || {}; + export const embeddedApi: EmbeddedSupersetApi = { getScrollSize, getDashboardPermalink, getActiveTabs, + getDataMask, }; diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx index 5145052c8be9779054a146d4658a169b189826c1..4263de96092d906daa829cbae9f8f16b67df504f 100644 --- a/superset-frontend/src/embedded/index.tsx +++ b/superset-frontend/src/embedded/index.tsx @@ -24,6 +24,7 @@ import Switchboard from '@superset-ui/switchboard'; import getBootstrapData from 'src/utils/getBootstrapData'; import setupClient from 'src/setup/setupClient'; import setupPlugins from 'src/setup/setupPlugins'; +import { useUiConfig } from 'src/components/UiConfigContext'; import { RootContextProviders } from 'src/views/RootContextProviders'; import { store, USER_LOADED } from 'src/views/store'; import ErrorBoundary from 'src/components/ErrorBoundary'; @@ -32,6 +33,7 @@ import { addDangerToast } from 'src/components/MessageToasts/actions'; import ToastContainer from 'src/components/MessageToasts/ToastContainer'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import { embeddedApi } from './api'; +import { getDataMaskChangeTrigger } from './utils'; setupPlugins(); @@ -51,11 +53,38 @@ const LazyDashboardPage = lazy( ), ); +const EmbededLazyDashboardPage = () => { + const uiConfig = useUiConfig(); + + // Emit data mask changes to the parent window + if (uiConfig?.emitDataMasks) { + log('setting up Switchboard event emitter'); + + let previousDataMask = store.getState().dataMask; + + store.subscribe(() => { + const currentState = store.getState(); + const currentDataMask = currentState.dataMask; + + // Only emit if the dataMask has changed + if (previousDataMask !== currentDataMask) { + Switchboard.emit('observeDataMask', { + ...currentDataMask, + ...getDataMaskChangeTrigger(currentDataMask, previousDataMask), + }); + previousDataMask = currentDataMask; + } + }); + } + + return <LazyDashboardPage idOrSlug={bootstrapData.embedded!.dashboard_id} />; +}; + const EmbeddedRoute = () => ( <Suspense fallback={<Loading />}> <RootContextProviders> <ErrorBoundary> - <LazyDashboardPage idOrSlug={bootstrapData.embedded!.dashboard_id} /> + <EmbededLazyDashboardPage /> </ErrorBoundary> <ToastContainer position="top" /> </RootContextProviders> @@ -209,6 +238,7 @@ window.addEventListener('message', function embeddedPageInitializer(event) { embeddedApi.getDashboardPermalink, ); Switchboard.defineMethod('getActiveTabs', embeddedApi.getActiveTabs); + Switchboard.defineMethod('getDataMask', embeddedApi.getDataMask); Switchboard.start(); } }); diff --git a/superset-frontend/src/embedded/utils.test.ts b/superset-frontend/src/embedded/utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..06ce75afa0f1f4d3970c57db0914162729bdac51 --- /dev/null +++ b/superset-frontend/src/embedded/utils.test.ts @@ -0,0 +1,76 @@ +/** + * 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 { DataMaskStateWithId } from '@superset-ui/core'; +import { cloneDeep } from 'lodash'; +import { getDataMaskChangeTrigger } from './utils'; + +const dataMask: DataMaskStateWithId = { + '1': { + id: '1', + extraFormData: {}, + filterState: {}, + ownState: {}, + }, + '2': { + id: '2', + extraFormData: {}, + filterState: {}, + ownState: {}, + }, + 'NATIVE_FILTER-1': { + id: 'NATIVE_FILTER-1', + extraFormData: {}, + filterState: { + value: null, + }, + ownState: {}, + }, + 'NATIVE_FILTER-2': { + id: 'NATIVE_FILTER-2', + extraFormData: {}, + filterState: {}, + ownState: {}, + }, +}; + +it('datamask didnt change - both triggers set to false', () => { + const previousDataMask = cloneDeep(dataMask); + expect(getDataMaskChangeTrigger(dataMask, previousDataMask)).toEqual({ + crossFiltersChanged: false, + nativeFiltersChanged: false, + }); +}); + +it('a native filter changed - nativeFiltersChanged set to true', () => { + const previousDataMask = cloneDeep(dataMask); + previousDataMask['NATIVE_FILTER-1'].filterState!.value = 'test'; + expect(getDataMaskChangeTrigger(dataMask, previousDataMask)).toEqual({ + crossFiltersChanged: false, + nativeFiltersChanged: true, + }); +}); + +it('a cross filter changed - crossFiltersChanged set to true', () => { + const previousDataMask = cloneDeep(dataMask); + previousDataMask['1'].filterState!.value = 'test'; + expect(getDataMaskChangeTrigger(dataMask, previousDataMask)).toEqual({ + crossFiltersChanged: true, + nativeFiltersChanged: false, + }); +}); diff --git a/superset-frontend/src/embedded/utils.ts b/superset-frontend/src/embedded/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..8645dd7f08a3650741684a1e05d4ad4a51dbdbe2 --- /dev/null +++ b/superset-frontend/src/embedded/utils.ts @@ -0,0 +1,46 @@ +/** + * 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 { DataMaskStateWithId } from '@superset-ui/core'; +import { isEmpty, isEqual } from 'lodash'; +import { NATIVE_FILTER_PREFIX } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/utils'; + +export const getDataMaskChangeTrigger = ( + dataMask: DataMaskStateWithId, + previousDataMask: DataMaskStateWithId, +) => { + let crossFiltersChanged = false; + let nativeFiltersChanged = false; + + if (!isEmpty(dataMask) && !isEmpty(previousDataMask)) { + for (const key in dataMask) { + if ( + key.startsWith(NATIVE_FILTER_PREFIX) && + !isEqual(dataMask[key], previousDataMask[key]) + ) { + nativeFiltersChanged = true; + break; + } else if (!isEqual(dataMask[key], previousDataMask[key])) { + crossFiltersChanged = true; + break; + } + } + } + return { crossFiltersChanged, nativeFiltersChanged }; +}; diff --git a/superset-frontend/src/explore/components/ControlHeader.tsx b/superset-frontend/src/explore/components/ControlHeader.tsx index cf94c24355174c206c7f244b1a4ee69db5999c25..750019dc4e9d41fb415663517c1aa59423f13836 100644 --- a/superset-frontend/src/explore/components/ControlHeader.tsx +++ b/superset-frontend/src/explore/components/ControlHeader.tsx @@ -21,7 +21,7 @@ import { t, css, useTheme, SupersetTheme } from '@superset-ui/core'; import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; import { Tooltip } from 'src/components/Tooltip'; import { FormLabel } from 'src/components/Form'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; type ValidationError = string; diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx index 77e5580fd7d67cb228b69cac8558cc3c1ef03db9..9f9937339ee01c73b701a8ea84948cd516347fed 100644 --- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx +++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx @@ -66,7 +66,7 @@ import { getSectionsToRender } from 'src/explore/controlUtils'; import { ExploreActions } from 'src/explore/actions/exploreActions'; import { ChartState, ExplorePageState } from 'src/explore/types'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import ControlRow from './ControlRow'; import Control from './Control'; import { ExploreAlert } from './ExploreAlert'; diff --git a/superset-frontend/src/explore/components/DataTableControl/index.tsx b/superset-frontend/src/explore/components/DataTableControl/index.tsx index a54dcb50938383515822656937723dbd572124c2..d95876213f1161217726154e56742520cd14a727 100644 --- a/superset-frontend/src/explore/components/DataTableControl/index.tsx +++ b/superset-frontend/src/explore/components/DataTableControl/index.tsx @@ -37,7 +37,7 @@ import { SLOW_DEBOUNCE, } from 'src/constants'; import { Radio } from 'src/components/Radio'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Button from 'src/components/Button'; import Popover from 'src/components/Popover'; import { prepareCopyToClipboardTabularData } from 'src/utils/common'; diff --git a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx index 65b3d60e23094fdb4296082827e063b3ff26b764..051caa405cda81a8d1028d729d088ad7da9f04d6 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx @@ -24,7 +24,7 @@ import { t, useTheme, } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Tabs from 'src/components/Tabs'; import { getItem, diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx index eb3ed8981ad66a3831f578441b12af7defb66a9e..aaafd6fa55d648e8b00dc1fea81bfcf5000d1312 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx @@ -25,7 +25,7 @@ import { StyledColumnOption, StyledMetricOption, } from 'src/explore/components/optionRenderers'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { DatasourcePanelDndItem } from '../types'; diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.tsx index 8ef29b99664aa33741647d65ced7cbcd3384041d..0ca8f92e16124afe1180363357a49ecbce93f16f 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.tsx @@ -20,7 +20,7 @@ import { CSSProperties, FC } from 'react'; import { css, Metric, styled, t, useTheme } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import DatasourcePanelDragOption from './DatasourcePanelDragOption'; import { DndItemType } from '../DndItemType'; import { DndItemValue } from './types'; diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx index b7e76b21701f9c1d99f40a6c82e7d3f096e0fb31..db7d19a706661a9ed5ead3b384cc3b967865d0fc 100644 --- a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx +++ b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx @@ -25,7 +25,7 @@ import { css, logging, SupersetClient, t, useTheme } from '@superset-ui/core'; import { chartPropShape } from 'src/dashboard/util/propShapes'; import AlteredSliceTag from 'src/components/AlteredSliceTag'; import Button from 'src/components/Button'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import PropertiesModal from 'src/explore/components/PropertiesModal'; import { sliceUpdated } from 'src/explore/actions/exploreActions'; import { PageHeaderWithActions } from 'src/components/PageHeaderWithActions'; diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index fa9279d0e208e9125a6b484f59626d60e2b8119a..e7f80d4e066ea6349dd79d194a19fff027a2f9d0 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -36,7 +36,7 @@ import { Resizable } from 're-resizable'; import { usePluginContext } from 'src/components/DynamicPlugins'; import { Global } from '@emotion/react'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { getItem, setItem, diff --git a/superset-frontend/src/explore/components/ExportToCSVDropdown/index.tsx b/superset-frontend/src/explore/components/ExportToCSVDropdown/index.tsx index 4307a6bca24cc0d0dac418d833ced88b037a4ca4..d539c4f47a7772d817be2b159b9ecb2cfde78d92 100644 --- a/superset-frontend/src/explore/components/ExportToCSVDropdown/index.tsx +++ b/superset-frontend/src/explore/components/ExportToCSVDropdown/index.tsx @@ -19,7 +19,7 @@ import { ReactChild, useCallback, Key } from 'react'; import { t, styled } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { Dropdown } from 'src/components/Dropdown'; import { Menu } from 'src/components/Menu'; diff --git a/superset-frontend/src/explore/components/PropertiesModal/index.tsx b/superset-frontend/src/explore/components/PropertiesModal/index.tsx index dba9031e81ea676db4e5df9214a0637b4f82b54a..7427949a9ab2d43225774a5083da9efe70f67bcf 100644 --- a/superset-frontend/src/explore/components/PropertiesModal/index.tsx +++ b/superset-frontend/src/explore/components/PropertiesModal/index.tsx @@ -36,7 +36,7 @@ import { useTheme, css, } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Chart, { Slice } from 'src/types/Chart'; import withToasts from 'src/components/MessageToasts/withToasts'; import { loadTags } from 'src/components/Tags/utils'; diff --git a/superset-frontend/src/explore/components/RunQueryButton/index.tsx b/superset-frontend/src/explore/components/RunQueryButton/index.tsx index 80e14fc3b0f6294b3a4f1872293263a140257684..c866f638e8eeb2c26a01dea3717b3baf9dcac40e 100644 --- a/superset-frontend/src/explore/components/RunQueryButton/index.tsx +++ b/superset-frontend/src/explore/components/RunQueryButton/index.tsx @@ -20,7 +20,7 @@ import { ReactNode } from 'react'; import { t, useTheme } from '@superset-ui/core'; import Button from 'src/components/Button'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; export type RunQueryButtonProps = { loading: boolean; diff --git a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.tsx b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.tsx index 6a5be7ce1fe2f77e5022451093e4c46ca2d1dcf0..2110feecd96429f5868e3cae2f87fd31604304e7 100644 --- a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.tsx +++ b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.tsx @@ -37,7 +37,7 @@ import CustomListItem from 'src/explore/components/controls/CustomListItem'; import { ChartState, ExplorePageState } from 'src/explore/types'; import { AnyAction } from 'redux'; import { ThunkDispatch } from 'redux-thunk'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import ControlPopover, { getSectionContainerElement, } from '../ControlPopover/ControlPopover'; diff --git a/superset-frontend/src/explore/components/controls/CollectionControl/index.jsx b/superset-frontend/src/explore/components/controls/CollectionControl/index.jsx index 7b25047c0a83cf38682cbdf85f3aef91a7de1fac..1e4c9b454d92ad365ec02d2879182892d221986d 100644 --- a/superset-frontend/src/explore/components/controls/CollectionControl/index.jsx +++ b/superset-frontend/src/explore/components/controls/CollectionControl/index.jsx @@ -27,7 +27,7 @@ import { SortableElement, arrayMove, } from 'react-sortable-hoc'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { HeaderContainer, AddIconButton, diff --git a/superset-frontend/src/explore/components/controls/ColorPickerControl.jsx b/superset-frontend/src/explore/components/controls/ColorPickerControl.jsx index 1d140db0ae765e4ed3d83110479ea9b6a77bf728..56b86e58947f517d6ea3ed1441f51a72936c3ea5 100644 --- a/superset-frontend/src/explore/components/controls/ColorPickerControl.jsx +++ b/superset-frontend/src/explore/components/controls/ColorPickerControl.jsx @@ -78,7 +78,7 @@ export default class ColorPickerControl extends Component { renderPopover() { const presetColors = getCategoricalSchemeRegistry() .get() - .colors.filter((s, i) => i < 7); + .colors.filter((s, i) => i < 9); return ( <div id="filter-popover" className="color-popover"> <SketchPicker @@ -87,6 +87,7 @@ export default class ColorPickerControl extends Component { padding: 0 !important; box-shadow: none !important; `} + width={235} color={this.props.value} onChange={this.onChange} presetColors={presetColors} diff --git a/superset-frontend/src/explore/components/controls/ColorSchemeControl/index.tsx b/superset-frontend/src/explore/components/controls/ColorSchemeControl/index.tsx index 40e0c463bf42d4cdfc20bf5a51eb794e3dd266e5..c1e620c6db8b7956c072782baadef2a2e9b006b4 100644 --- a/superset-frontend/src/explore/components/controls/ColorSchemeControl/index.tsx +++ b/superset-frontend/src/explore/components/controls/ColorSchemeControl/index.tsx @@ -33,7 +33,7 @@ import AntdSelect from 'antd/lib/select'; // TODO: Remove antd import { sortBy } from 'lodash'; import ControlHeader from 'src/explore/components/ControlHeader'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { SelectOptionsType } from 'src/components/Select/types'; import { StyledSelect } from 'src/components/Select/styles'; import { handleFilterOptionHelper } from 'src/components/Select/utils'; diff --git a/superset-frontend/src/explore/components/controls/ColumnConfigControl/constants.tsx b/superset-frontend/src/explore/components/controls/ColumnConfigControl/constants.tsx index fbe7da65447570f12c6cb79c83bb29dbb778eddb..2d98f1bb434c1312fb001730872c98d6309abf79 100644 --- a/superset-frontend/src/explore/components/controls/ColumnConfigControl/constants.tsx +++ b/superset-frontend/src/explore/components/controls/ColumnConfigControl/constants.tsx @@ -24,7 +24,7 @@ import { D3_TIME_FORMAT_DOCS, D3_TIME_FORMAT_OPTIONS, } from '@superset-ui/chart-controls'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { ColumnConfigFormLayout } from './types'; export type SharedColumnConfigProp = @@ -38,7 +38,8 @@ export type SharedColumnConfigProp = | 'horizontalAlign' | 'truncateLongCells' | 'showCellBars' - | 'currencyFormat'; + | 'currencyFormat' + | 'visible'; const d3NumberFormat: ControlFormItemSpec<'Select'> = { allowNewOptions: true, @@ -152,6 +153,14 @@ const currencyFormat: ControlFormItemSpec<'CurrencyControl'> = { ), debounceDelay: 200, }; + +const visible: ControlFormItemSpec<'Checkbox'> = { + controlType: 'Checkbox', + label: t('Display in chart'), + description: t('Whether to display in the chart'), + defaultValue: true, + debounceDelay: 200, +}; /** * All configurable column formatting properties. */ @@ -174,6 +183,7 @@ export const SHARED_COLUMN_CONFIG_PROPS = { alignPositiveNegative, colorPositiveNegative, currencyFormat, + visible, }; export const DEFAULT_CONFIG_FORM_LAYOUT: ColumnConfigFormLayout = { diff --git a/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/ConditionalFormattingControl.tsx b/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/ConditionalFormattingControl.tsx index aabfade6b2fdd91029a30cca883f30de92997e3c..3b5b9d9490480be98e5be85771c96d98c42302d6 100644 --- a/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/ConditionalFormattingControl.tsx +++ b/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/ConditionalFormattingControl.tsx @@ -19,7 +19,7 @@ import { useEffect, useState } from 'react'; import { styled, css, t, useTheme } from '@superset-ui/core'; import { Comparator } from '@superset-ui/chart-controls'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import ControlHeader from 'src/explore/components/ControlHeader'; import { FormattingPopover } from './FormattingPopover'; import { diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx b/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx index 8f866af430cdedcbd62f374bd49ae4da582af82b..9bea9432b215566e004f87f496581358e11e13a3 100644 --- a/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx +++ b/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx @@ -33,7 +33,7 @@ import { getUrlParam } from 'src/utils/urlUtils'; import { Dropdown } from 'src/components/Dropdown'; import { Menu } from 'src/components/Menu'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { ChangeDatasourceModal, DatasourceModal, diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterLabel.tsx b/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterLabel.tsx index 5f200ea5895d00280c455cc3aee1bc71be5a6fcf..421019e6537b0ae23a2506320ebffdd3e3738360 100644 --- a/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterLabel.tsx +++ b/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterLabel.tsx @@ -31,7 +31,7 @@ import Button from 'src/components/Button'; import ControlHeader from 'src/explore/components/ControlHeader'; import Modal from 'src/components/Modal'; import { Divider } from 'src/components/Divider'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Select from 'src/components/Select/Select'; import { Tooltip } from 'src/components/Tooltip'; import { useDebouncedEffect } from 'src/explore/exploreUtils'; diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/components/DateLabel.tsx b/superset-frontend/src/explore/components/controls/DateFilterControl/components/DateLabel.tsx index e44c0322a0928d28ac536f9852a4ad4da8aa592e..859b19d4e5dd5c22a8dd38a8d4a9a12c0c7c2fb7 100644 --- a/superset-frontend/src/explore/components/controls/DateFilterControl/components/DateLabel.tsx +++ b/superset-frontend/src/explore/components/controls/DateFilterControl/components/DateLabel.tsx @@ -20,7 +20,7 @@ import { forwardRef, MouseEvent, ReactNode, RefObject } from 'react'; import { css, styled, t } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; export type DateLabelProps = { name?: string; diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelectPopoverTitle.jsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelectPopoverTitle.jsx index a4dec16e3f35f53475eb337198383efd0535538b..4b2dc77ae16c58f055402e01ed2b9183b9009052 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelectPopoverTitle.jsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelectPopoverTitle.jsx @@ -20,7 +20,7 @@ import { useCallback, useState } from 'react'; import { t, styled, useTheme } from '@superset-ui/core'; import { Input } from 'src/components/Input'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; const StyledInput = styled(Input)` border-radius: ${({ theme }) => theme.borderRadius}; diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx index e2dd15674fe3c5cba1f30979bd1605c60d779d75..3ef13317455bbac61bc72fa4d56fdc4b07355030 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx @@ -29,7 +29,7 @@ import { DatasourcePanelDndItem, DndItemValue, } from 'src/explore/components/DatasourcePanel/types'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { DndItemType } from '../../DndItemType'; import { DraggingContext, DropzoneContext } from '../../ExploreContainer'; diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/Option.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/Option.tsx index 2234bef94df84df7b25359aac6c5fd54ca0e91ea..b85fed4662989033ff03dc9ee5fdb82e681c077f 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/Option.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/Option.tsx @@ -18,7 +18,7 @@ */ import { useCallback } from 'react'; import { css, styled, t, useTheme } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { CaretContainer, CloseContainer, diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.jsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.jsx index 85186773f6baf9ac8ff4dff48c972a7394932355..de3b515fe059f7a93ad70fe59de82df6f155dfad 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.jsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.jsx @@ -42,7 +42,7 @@ import { HeaderContainer, LabelsContainer, } from 'src/explore/components/controls/OptionControls'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Modal from 'src/components/Modal'; import AdhocFilterPopoverTrigger from 'src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger'; import AdhocFilterOption from 'src/explore/components/controls/FilterControl/AdhocFilterOption'; diff --git a/superset-frontend/src/explore/components/controls/FixedOrMetricControl/FixedOrMetricControl.test.tsx b/superset-frontend/src/explore/components/controls/FixedOrMetricControl/FixedOrMetricControl.test.tsx index 4ba35939d36ac6d1923e71ead0491d533bd187c6..ccf627808f21d9ad5d2b5c2c5bda62d7bf554727 100644 --- a/superset-frontend/src/explore/components/controls/FixedOrMetricControl/FixedOrMetricControl.test.tsx +++ b/superset-frontend/src/explore/components/controls/FixedOrMetricControl/FixedOrMetricControl.test.tsx @@ -20,7 +20,7 @@ import { render, screen, userEvent } from 'spec/helpers/testing-library'; import FixedOrMetricControl from '.'; jest.mock( - 'src/components/Icons/Icon', + 'src/components/Icons/AsyncIcon', () => ({ fileName }: { fileName: string }) => ( <span role="img" aria-label={fileName.replace('_', '-')} /> diff --git a/superset-frontend/src/explore/components/controls/LayerConfigsControl/FlatLayerTree.tsx b/superset-frontend/src/explore/components/controls/LayerConfigsControl/FlatLayerTree.tsx index 4e48988ded931e07abaa78503b3d76dec5c6cb62..604cfb99294b4b4638999fb63aed349f015ed06f 100644 --- a/superset-frontend/src/explore/components/controls/LayerConfigsControl/FlatLayerTree.tsx +++ b/superset-frontend/src/explore/components/controls/LayerConfigsControl/FlatLayerTree.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { css, styled, t } from '@superset-ui/core'; // eslint-disable-next-line no-restricted-imports import { Button, Tree } from 'antd'; // TODO: Remove antd diff --git a/superset-frontend/src/explore/components/controls/LayerConfigsControl/LayerTreeItem.tsx b/superset-frontend/src/explore/components/controls/LayerConfigsControl/LayerTreeItem.tsx index a92cc98fe3787f2d894ebd4fafb586fc150e76c8..4fcbf0ec069c99cf35789a026774365a4dabfeaa 100644 --- a/superset-frontend/src/explore/components/controls/LayerConfigsControl/LayerTreeItem.tsx +++ b/superset-frontend/src/explore/components/controls/LayerConfigsControl/LayerTreeItem.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; // eslint-disable-next-line no-restricted-imports import { Button, Tag } from 'antd'; // TODO: Remove antd import { FC } from 'react'; diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopoverTitle.tsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopoverTitle.tsx index 41d6649c94aee38724886910597c34aaa28d66c1..753b5e75dfbe9cda0057e5a885ac51a67e95fedb 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopoverTitle.tsx +++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopoverTitle.tsx @@ -28,7 +28,7 @@ import { import { t, styled, useTheme } from '@superset-ui/core'; import { Input } from 'src/components/Input'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; const TitleLabel = styled.span` display: inline-block; diff --git a/superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.jsx b/superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.jsx index 0376be5e6dc1655163d8c494129650b5df31f759..b94f790e32dedc3c61671c107e41506a642c3de8 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.jsx +++ b/superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.jsx @@ -21,7 +21,7 @@ import PropTypes from 'prop-types'; import { ensureIsArray, t, useTheme, usePrevious } from '@superset-ui/core'; import { isEqual } from 'lodash'; import ControlHeader from 'src/explore/components/ControlHeader'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { AddIconButton, AddControlLabel, diff --git a/superset-frontend/src/explore/components/controls/OptionControls/index.tsx b/superset-frontend/src/explore/components/controls/OptionControls/index.tsx index 38cc93437918ef833e20177c48eb8949350aaf47..5d52db70bbc0fd283c830e0c9a712aa96873d2dd 100644 --- a/superset-frontend/src/explore/components/controls/OptionControls/index.tsx +++ b/superset-frontend/src/explore/components/controls/OptionControls/index.tsx @@ -22,7 +22,7 @@ import { useDrag, useDrop, DropTargetMonitor } from 'react-dnd'; import { styled, t, useTheme, keyframes, css } from '@superset-ui/core'; import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { savedMetricType } from 'src/explore/components/controls/MetricControl/types'; import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric'; import { StyledMetricOption } from '../../optionRenderers'; diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl/FastVizSwitcher.tsx b/superset-frontend/src/explore/components/controls/VizTypeControl/FastVizSwitcher.tsx index dafdfb6a342273278da9fa08e02f2abeed78ba42..ebb19884fd5508b2b2830d98014e4ba45883be80 100644 --- a/superset-frontend/src/explore/components/controls/VizTypeControl/FastVizSwitcher.tsx +++ b/superset-frontend/src/explore/components/controls/VizTypeControl/FastVizSwitcher.tsx @@ -19,7 +19,7 @@ import { memo, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { css, SupersetTheme } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { getChartKey } from 'src/explore/exploreUtils'; import { ExplorePageState } from 'src/explore/types'; import { FastVizSwitcherProps } from './types'; diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeControl.test.tsx b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeControl.test.tsx index f427bc9f038df0a5413ae0faaf9f337ff622cf99..9f02cc28f2657c6c4e35c5af4edca3c092f37a01 100644 --- a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeControl.test.tsx +++ b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeControl.test.tsx @@ -121,7 +121,7 @@ describe('VizTypeControl', () => { }; await waitForRenderWrapper(props); expect(screen.getByLabelText('table')).toBeVisible(); - expect(screen.getByLabelText('big-number-chart-tile')).toBeVisible(); + expect(screen.getByLabelText('big-number_chart_tile')).toBeVisible(); expect(screen.getByLabelText('pie-chart')).toBeVisible(); expect(screen.getByLabelText('bar-chart')).toBeVisible(); expect(screen.getByLabelText('area-chart')).toBeVisible(); diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx index 85236ca3a4527823713ad950d49f0fc16ac6c04b..13512e5d8cdc1c939e9c515360fbcbd94ba7be48 100644 --- a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx +++ b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx @@ -44,7 +44,7 @@ import { Tooltip } from 'src/components/Tooltip'; import { Input } from 'src/components/Input'; import Label from 'src/components/Label'; import { usePluginContext } from 'src/components/DynamicPlugins'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { nativeFilterGate } from 'src/dashboard/components/nativeFilters/utils'; import scrollIntoView from 'scroll-into-view-if-needed'; diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl/constants.tsx b/superset-frontend/src/explore/components/controls/VizTypeControl/constants.tsx index 1d7b0206b8b13bde3b0cd8409dbd9200dea2b83f..c57513ab4d0cea3fec03b56a1a9d4afe7374f24e 100644 --- a/superset-frontend/src/explore/components/controls/VizTypeControl/constants.tsx +++ b/superset-frontend/src/explore/components/controls/VizTypeControl/constants.tsx @@ -17,7 +17,7 @@ * under the License. */ import { VizType } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { VizMeta } from './types'; export const FEATURED_CHARTS: VizMeta[] = [ diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx index 44db4de824dffd373c203d9f903098b72804c06c..8101621712a1c8df2593f1e3cdae90aceda0bf9d 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx @@ -19,7 +19,7 @@ import { useState } from 'react'; import { css, t, useTheme } from '@superset-ui/core'; import { Input } from 'src/components/Input'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { Menu } from 'src/components/Menu'; import { Link } from 'react-router-dom'; diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx index 6f7b64bc2e1d15a03fdcb77c19f5b3a1cacec500..f605a96b54ac1d281dc9cf23c5d8b56986499344 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx @@ -27,7 +27,7 @@ import { useTheme, VizType, } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { Menu } from 'src/components/Menu'; import ModalTrigger from 'src/components/ModalTrigger'; import Button from 'src/components/Button'; diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx b/superset-frontend/src/features/alerts/AlertReportModal.tsx index 0f619e8fa59acdd1d1caefd7787d4d4cc66ee862..ba2e9db94571f30d4f17b5de3b88d958620c74da 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx @@ -71,7 +71,7 @@ import { } from 'src/features/alerts/types'; import { useSelector } from 'react-redux'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import NumberInput from './components/NumberInput'; import { AlertReportCronScheduler } from './components/AlertReportCronScheduler'; import { NotificationMethod } from './components/NotificationMethod'; diff --git a/superset-frontend/src/features/alerts/components/AlertStatusIcon.tsx b/superset-frontend/src/features/alerts/components/AlertStatusIcon.tsx index 9dbe34530096c62249a83b5f6b43c1d94732367e..d80d982e946f38c7744bbe3e1050afb72e7b8741 100644 --- a/superset-frontend/src/features/alerts/components/AlertStatusIcon.tsx +++ b/superset-frontend/src/features/alerts/components/AlertStatusIcon.tsx @@ -18,7 +18,7 @@ */ import { t, SupersetTheme, useTheme } from '@superset-ui/core'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { AlertState } from '../types'; function getStatusColor( diff --git a/superset-frontend/src/features/alerts/components/NotificationMethod.tsx b/superset-frontend/src/features/alerts/components/NotificationMethod.tsx index 46613607daae80f1d387e8d3ec02b7537c0589a3..0266b5c64e96d8ebb1e0eea2b621c0b0afbd784a 100644 --- a/superset-frontend/src/features/alerts/components/NotificationMethod.tsx +++ b/superset-frontend/src/features/alerts/components/NotificationMethod.tsx @@ -35,7 +35,7 @@ import { useTheme, } from '@superset-ui/core'; import { Select } from 'src/components'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import RefreshLabel from 'src/components/RefreshLabel'; import { NotificationMethodOption, diff --git a/superset-frontend/src/features/alerts/components/RecipientIcon.tsx b/superset-frontend/src/features/alerts/components/RecipientIcon.tsx index 13dc2b92c7bebdd09309b7a5b5aa2c0a36674d67..05a131bf13637ba2ec336847c6fda0954c2e2e92 100644 --- a/superset-frontend/src/features/alerts/components/RecipientIcon.tsx +++ b/superset-frontend/src/features/alerts/components/RecipientIcon.tsx @@ -19,7 +19,7 @@ import { SupersetTheme, css } from '@superset-ui/core'; import { ReactElement } from 'react'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { NotificationMethodOption } from '../types'; const notificationStyledIcon = (theme: SupersetTheme) => css` diff --git a/superset-frontend/src/features/alerts/components/ValidatedPanelHeader.tsx b/superset-frontend/src/features/alerts/components/ValidatedPanelHeader.tsx index 611161bc541f07df2ae332291193513c7a86bdc7..46411bbc3fa1131c326fcb8e523172b9c6bc9870 100644 --- a/superset-frontend/src/features/alerts/components/ValidatedPanelHeader.tsx +++ b/superset-frontend/src/features/alerts/components/ValidatedPanelHeader.tsx @@ -17,7 +17,7 @@ * under the License. */ import { t, useTheme } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; const ValidatedPanelHeader = ({ title, diff --git a/superset-frontend/src/features/annotationLayers/AnnotationLayerModal.tsx b/superset-frontend/src/features/annotationLayers/AnnotationLayerModal.tsx index 2af51d05105fbc6f96512f46cd3180538f6be54d..726c2a51ce39b23236e2111427c29c714173b0ce 100644 --- a/superset-frontend/src/features/annotationLayers/AnnotationLayerModal.tsx +++ b/superset-frontend/src/features/annotationLayers/AnnotationLayerModal.tsx @@ -21,7 +21,7 @@ import { FunctionComponent, useState, useEffect, ChangeEvent } from 'react'; import { css, styled, t, useTheme } from '@superset-ui/core'; import { useSingleViewResource } from 'src/views/CRUD/hooks'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Modal from 'src/components/Modal'; import withToasts from 'src/components/MessageToasts/withToasts'; diff --git a/superset-frontend/src/features/annotations/AnnotationModal.tsx b/superset-frontend/src/features/annotations/AnnotationModal.tsx index 1889177909fdf1bcf73f1b4f20410be76311e79c..3b1fc92bdf52daf8e3b23872e39649f05fd0c856 100644 --- a/superset-frontend/src/features/annotations/AnnotationModal.tsx +++ b/superset-frontend/src/features/annotations/AnnotationModal.tsx @@ -22,7 +22,7 @@ import { css, styled, t, useTheme } from '@superset-ui/core'; import { useSingleViewResource } from 'src/views/CRUD/hooks'; import { RangePicker } from 'src/components/DatePicker'; import { extendedDayjs } from 'src/utils/dates'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Modal from 'src/components/Modal'; import withToasts from 'src/components/MessageToasts/withToasts'; import { JsonEditor } from 'src/components/AsyncAceEditor'; diff --git a/superset-frontend/src/features/charts/ChartCard.tsx b/superset-frontend/src/features/charts/ChartCard.tsx index c841201752b82ece41aa7c79e794c69cbe2ad75c..b662670133762d6040011875fb19e243b5938bdf 100644 --- a/superset-frontend/src/features/charts/ChartCard.tsx +++ b/superset-frontend/src/features/charts/ChartCard.tsx @@ -19,7 +19,7 @@ import { isFeatureEnabled, FeatureFlag, t, css } from '@superset-ui/core'; import { Link, useHistory } from 'react-router-dom'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Chart from 'src/types/Chart'; import ListViewCard from 'src/components/ListViewCard'; diff --git a/superset-frontend/src/features/cssTemplates/CssTemplateModal.tsx b/superset-frontend/src/features/cssTemplates/CssTemplateModal.tsx index e325b08fd3883c6d65183919833ee0286f27d979..85a90d60898e4832188674a0240cf078aaad13e2 100644 --- a/superset-frontend/src/features/cssTemplates/CssTemplateModal.tsx +++ b/superset-frontend/src/features/cssTemplates/CssTemplateModal.tsx @@ -21,7 +21,7 @@ import { FunctionComponent, useState, useEffect, ChangeEvent } from 'react'; import { css, styled, t, useTheme } from '@superset-ui/core'; import { useSingleViewResource } from 'src/views/CRUD/hooks'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Modal from 'src/components/Modal'; import withToasts from 'src/components/MessageToasts/withToasts'; import { CssEditor } from 'src/components/AsyncAceEditor'; diff --git a/superset-frontend/src/features/dashboards/DashboardCard.tsx b/superset-frontend/src/features/dashboards/DashboardCard.tsx index edfd28f6d0994c85139c08249c9f1ac558f8e542..fb5bae9c1112e359093752862dc92555037757bc 100644 --- a/superset-frontend/src/features/dashboards/DashboardCard.tsx +++ b/superset-frontend/src/features/dashboards/DashboardCard.tsx @@ -28,7 +28,7 @@ import { CardStyles } from 'src/views/CRUD/utils'; import { Dropdown } from 'src/components/Dropdown'; import { Menu } from 'src/components/Menu'; import ListViewCard from 'src/components/ListViewCard'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { PublishedLabel } from 'src/components/Label'; import FacePile from 'src/components/FacePile'; import FaveStar from 'src/components/FaveStar'; diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.tsx index 93b9ce7cb3d83a41e78535303c0500e9138e96bb..c10859993a2888e3c3e5a5e1657eee1fc1cf4b5b 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.tsx @@ -20,7 +20,7 @@ import { useRef, useState } from 'react'; import { SupersetTheme, t } from '@superset-ui/core'; import { Button, AntdSelect } from 'src/components'; import FormLabel from 'src/components/Form/FormLabel'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { DatabaseParameters, FieldPropTypes } from '../../types'; import { infoTooltip, CredentialInfoForm } from '../styles'; diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx index 91b6a63c0bc3f1b5edeabef828706d10c9f3da81..cb300543647188493f6b10fee6e76117674631d5 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx @@ -19,7 +19,7 @@ import { css, SupersetTheme, t } from '@superset-ui/core'; import ValidatedInput from 'src/components/Form/LabeledErrorBoundInput'; import FormLabel from 'src/components/Form/FormLabel'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { StyledFooterButton, StyledCatalogTable } from '../styles'; import { CatalogObject, FieldPropTypes } from '../../types'; diff --git a/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx b/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx index 7a4a72ff5381b996c6c8e7064757dc7e35d110d5..2a1daf4823ba0fd4d249839b11dd93bdcc5a3628 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx @@ -24,7 +24,7 @@ import { Radio } from 'src/components/Radio'; import { Input, TextArea } from 'src/components/Input'; // eslint-disable-next-line no-restricted-imports import { Input as AntdInput, Tooltip } from 'antd'; // TODO: Remove antd -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { DatabaseObject, FieldPropTypes } from '../types'; import { AuthType } from '.'; diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.tsx index 055c97ebd96a42b970f258b0b86859a828c6d355..8b359cc141b153827b73aaa2dafddaea7d915b6b 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx @@ -45,13 +45,13 @@ import { AntdSelect, Upload } from 'src/components'; import Alert from 'src/components/Alert'; import Modal from 'src/components/Modal'; import Button from 'src/components/Button'; -import IconButton from 'src/components/IconButton'; +import { IconButton } from 'src/components/IconButton'; import InfoTooltip from 'src/components/InfoTooltip'; import withToasts from 'src/components/MessageToasts/withToasts'; import ValidatedInput from 'src/components/Form/LabeledErrorBoundInput'; import ErrorMessageWithStackTrace from 'src/components/ErrorMessage/ErrorMessageWithStackTrace'; import ErrorAlert from 'src/components/ImportModal/ErrorAlert'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { testDatabaseConnection, useSingleViewResource, diff --git a/superset-frontend/src/features/databases/UploadDataModel/index.tsx b/superset-frontend/src/features/databases/UploadDataModel/index.tsx index 8235049283dd4454a06a784a6b737734a0124abb..55681b5c1b81684be6860a0d776f6d46464ee0d9 100644 --- a/superset-frontend/src/features/databases/UploadDataModel/index.tsx +++ b/superset-frontend/src/features/databases/UploadDataModel/index.tsx @@ -43,7 +43,7 @@ import { Select, Upload, } from 'src/components'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { Input, InputNumber } from 'src/components/Input'; import rison from 'rison'; // eslint-disable-next-line no-restricted-imports @@ -793,7 +793,7 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({ allowClear allowNewOptions placeholder={t( - 'A comma separated list of columns that should be parsed as dates', + 'Select column names from a dropdown list that should be parsed as dates.', )} /> </StyledFormItem> diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.test.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.test.tsx index 7e5376fcfd6aa3231c8edad513222c776ae52c81..8e553632761b5f984d03278b5069a438257bbcb9 100644 --- a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.test.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.test.tsx @@ -36,7 +36,7 @@ import { } from './MessageContent'; jest.mock( - 'src/components/Icons/Icon', + 'src/components/Icons/AsyncIcon', () => ({ fileName }: { fileName: string }) => ( <span role="img" aria-label={fileName.replace('_', '-')} /> diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.tsx index 103467b629a5b779cf4dd197475badb40a38a1b3..f838c86f0715d205c598d861b4b08d0fcef9ad39 100644 --- a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.tsx @@ -17,7 +17,7 @@ * under the License. */ import { t, styled } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Alert from 'src/components/Alert'; import Table, { ColumnsType, TableSize } from 'src/components/Table'; // @ts-ignore diff --git a/superset-frontend/src/features/datasets/AddDataset/EditDataset/UsageTab/index.tsx b/superset-frontend/src/features/datasets/AddDataset/EditDataset/UsageTab/index.tsx index 289a67f5aa98f8acedbe708bc068c49f5f25b6f1..32786105b96fa9c1b2e36e841448d554077c1665 100644 --- a/superset-frontend/src/features/datasets/AddDataset/EditDataset/UsageTab/index.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/EditDataset/UsageTab/index.tsx @@ -34,7 +34,7 @@ import Table, { } from 'src/components/Table'; import { EmptyState } from 'src/components/EmptyState'; import ChartImage from 'src/assets/images/chart.svg'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { useToasts } from 'src/components/MessageToasts/withToasts'; import { useListViewResource } from 'src/views/CRUD/hooks'; import { FilterOperator } from 'src/components/ListView'; diff --git a/superset-frontend/src/features/datasets/AddDataset/Header/index.tsx b/superset-frontend/src/features/datasets/AddDataset/Header/index.tsx index b2f8f475aa59195ea89e4a2a09c6566e9fd9f584..60fc8a22a6c9fa6a8e544258b2ef894bd62e1eb0 100644 --- a/superset-frontend/src/features/datasets/AddDataset/Header/index.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/Header/index.tsx @@ -20,7 +20,7 @@ import { Dispatch } from 'react'; import { t } from '@superset-ui/core'; import { PageHeaderWithActions } from 'src/components/PageHeaderWithActions'; import Button from 'src/components/Button'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { Menu } from 'src/components/Menu'; import { TooltipPlacement } from 'src/components/Tooltip'; import { diff --git a/superset-frontend/src/features/home/ActivityTable.tsx b/superset-frontend/src/features/home/ActivityTable.tsx index c6a6d2359a28ec00dba981a9edd56135db871813..5974855e70dc8493a8598eab72a1e1cd82a216ce 100644 --- a/superset-frontend/src/features/home/ActivityTable.tsx +++ b/superset-frontend/src/features/home/ActivityTable.tsx @@ -30,7 +30,7 @@ import { getEditedObjects, } from 'src/views/CRUD/utils'; import { Chart } from 'src/types/Chart'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import SubMenu from './SubMenu'; import EmptyState from './EmptyState'; import { WelcomeTable, RecentActivity } from './types'; diff --git a/superset-frontend/src/features/home/ChartTable.tsx b/superset-frontend/src/features/home/ChartTable.tsx index 38ec864259464cdea7b4cb4a409df1b49db90c74..66222d6755da08d511fb90b229955bbd19c84ec3 100644 --- a/superset-frontend/src/features/home/ChartTable.tsx +++ b/superset-frontend/src/features/home/ChartTable.tsx @@ -44,7 +44,7 @@ import Chart from 'src/types/Chart'; import handleResourceExport from 'src/utils/export'; import Loading from 'src/components/Loading'; import ErrorBoundary from 'src/components/ErrorBoundary'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import EmptyState from './EmptyState'; import { WelcomeTable } from './types'; import SubMenu from './SubMenu'; diff --git a/superset-frontend/src/features/home/DashboardTable.tsx b/superset-frontend/src/features/home/DashboardTable.tsx index 56a65a5cb404f0695b7b65782310901afed9cef9..7dfab69652471ed3e4ce65ac775ab44c31d4b368 100644 --- a/superset-frontend/src/features/home/DashboardTable.tsx +++ b/superset-frontend/src/features/home/DashboardTable.tsx @@ -40,7 +40,7 @@ import Loading from 'src/components/Loading'; import DeleteModal from 'src/components/DeleteModal'; import PropertiesModal from 'src/dashboard/components/PropertiesModal'; import DashboardCard from 'src/features/dashboards/DashboardCard'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import EmptyState from './EmptyState'; import SubMenu from './SubMenu'; import { WelcomeTable } from './types'; diff --git a/superset-frontend/src/features/home/LanguagePicker.tsx b/superset-frontend/src/features/home/LanguagePicker.tsx index da181d3821fdbe3760b5756d79a830802cf675cd..90a492062eec7a80fc9779db5fe37d116a05cfad 100644 --- a/superset-frontend/src/features/home/LanguagePicker.tsx +++ b/superset-frontend/src/features/home/LanguagePicker.tsx @@ -18,7 +18,7 @@ */ import { MainNav as Menu } from 'src/components/Menu'; import { styled, css, useTheme } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; const { SubMenu } = Menu; export interface Languages { diff --git a/superset-frontend/src/features/home/Menu.tsx b/superset-frontend/src/features/home/Menu.tsx index 4d24fcb43283af9a34c2413a0051e11800242cb6..3a15f4ff5e8882a304ea9243b891a85d208d265f 100644 --- a/superset-frontend/src/features/home/Menu.tsx +++ b/superset-frontend/src/features/home/Menu.tsx @@ -25,7 +25,7 @@ import { MainNav, MenuMode } from 'src/components/Menu'; import { Tooltip } from 'src/components/Tooltip'; import { NavLink, useLocation } from 'react-router-dom'; import { GenericLink } from 'src/components/GenericLink/GenericLink'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { useUiConfig } from 'src/components/UiConfigContext'; import { URL_PARAMS } from 'src/constants'; import { diff --git a/superset-frontend/src/features/home/RightMenu.tsx b/superset-frontend/src/features/home/RightMenu.tsx index d798d037e715a9c9d176350956b6dfdc65108fba..8d35c6a64a79376576e60b8b0a10d092c4ac8ab8 100644 --- a/superset-frontend/src/features/home/RightMenu.tsx +++ b/superset-frontend/src/features/home/RightMenu.tsx @@ -37,7 +37,7 @@ import { } from '@superset-ui/core'; import { Menu } from 'src/components/Menu'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Label from 'src/components/Label'; import { findPermission } from 'src/utils/findPermission'; import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; diff --git a/superset-frontend/src/features/home/SavedQueries.tsx b/superset-frontend/src/features/home/SavedQueries.tsx index 7258e6d40171d335fd47057ecc84edd4b7719ee7..65c34a8bfc43a6afb3b3e81420d9f3bb016ca687 100644 --- a/superset-frontend/src/features/home/SavedQueries.tsx +++ b/superset-frontend/src/features/home/SavedQueries.tsx @@ -30,7 +30,7 @@ import { Menu } from 'src/components/Menu'; import { copyQueryLink, useListViewResource } from 'src/views/CRUD/hooks'; import ListViewCard from 'src/components/ListViewCard'; import DeleteModal from 'src/components/DeleteModal'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { User } from 'src/types/bootstrapTypes'; import { CardContainer, diff --git a/superset-frontend/src/features/home/SubMenu.tsx b/superset-frontend/src/features/home/SubMenu.tsx index 7f917a14a8515fcee0bc23f75d1ae76c267bd3df..dd03fba9d1326dca5c3fa074159c4faecd7e270b 100644 --- a/superset-frontend/src/features/home/SubMenu.tsx +++ b/superset-frontend/src/features/home/SubMenu.tsx @@ -26,7 +26,7 @@ import { debounce } from 'lodash'; import { Row } from 'src/components'; import { Menu, MenuMode, MainNav } from 'src/components/Menu'; import Button, { OnClickHandler } from 'src/components/Button'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { MenuObjectProps } from 'src/types/bootstrapTypes'; const StyledHeader = styled.div` @@ -134,6 +134,7 @@ export interface ButtonProps { | 'warning' | 'success' | 'tertiary'; + loading?: boolean; } export interface SubMenuProps { @@ -282,6 +283,7 @@ const SubMenuComponent: FunctionComponent<SubMenuProps> = props => { buttonStyle={btn.buttonStyle} onClick={btn.onClick} data-test={btn['data-test']} + loading={btn.loading ?? false} > {btn.name} </Button> diff --git a/superset-frontend/src/features/queries/SyntaxHighlighterCopy.tsx b/superset-frontend/src/features/queries/SyntaxHighlighterCopy.tsx index b144d71c41f654a5034212936b246e0e7c89f7ac..6a019141c142454c453fb2a84ff39a28fb2ca357 100644 --- a/superset-frontend/src/features/queries/SyntaxHighlighterCopy.tsx +++ b/superset-frontend/src/features/queries/SyntaxHighlighterCopy.tsx @@ -24,7 +24,7 @@ import markdownSyntax from 'react-syntax-highlighter/dist/cjs/languages/hljs/mar import jsonSyntax from 'react-syntax-highlighter/dist/cjs/languages/hljs/json'; import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github'; import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { ToastProps } from 'src/components/MessageToasts/withToasts'; import copyTextToClipboard from 'src/utils/copy'; diff --git a/superset-frontend/src/features/reports/ReportModal/HeaderReportDropdown/index.tsx b/superset-frontend/src/features/reports/ReportModal/HeaderReportDropdown/index.tsx index ad2b2fa86241e16d0063e9be82643d46fcfb23c3..2fff224dbc7b571a261c371c62f783751c7b88d4 100644 --- a/superset-frontend/src/features/reports/ReportModal/HeaderReportDropdown/index.tsx +++ b/superset-frontend/src/features/reports/ReportModal/HeaderReportDropdown/index.tsx @@ -29,7 +29,7 @@ import { getExtensionsRegistry, usePrevious, } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { Switch } from 'src/components/Switch'; import { AlertObject } from 'src/features/alerts/types'; import { Menu } from 'src/components/Menu'; diff --git a/superset-frontend/src/features/reports/ReportModal/index.tsx b/superset-frontend/src/features/reports/ReportModal/index.tsx index aa84619eba509991e2a0af3d8d4d62106b9834e8..2d7e6c85fd9b23be76d1c8fd2760271029f87ac1 100644 --- a/superset-frontend/src/features/reports/ReportModal/index.tsx +++ b/superset-frontend/src/features/reports/ReportModal/index.tsx @@ -39,7 +39,7 @@ import { import Alert from 'src/components/Alert'; import TimezoneSelector from 'src/components/TimezoneSelector'; import LabeledErrorBoundInput from 'src/components/Form/LabeledErrorBoundInput'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { CronError } from 'src/components/CronPicker'; import { Radio, RadioChangeEvent } from 'src/components/Radio'; import { Input } from 'src/components/Input'; diff --git a/superset-frontend/src/features/rls/RowLevelSecurityModal.tsx b/superset-frontend/src/features/rls/RowLevelSecurityModal.tsx index 6f155afe840ec4e5b54aa21c7ae7828adbd31bb2..70461fc412a2b1adc9e1e07c51616d5aa8aeab0b 100644 --- a/superset-frontend/src/features/rls/RowLevelSecurityModal.tsx +++ b/superset-frontend/src/features/rls/RowLevelSecurityModal.tsx @@ -20,7 +20,7 @@ import { css, styled, SupersetClient, useTheme, t } from '@superset-ui/core'; import Modal from 'src/components/Modal'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import Select from 'src/components/Select/Select'; import { TextArea } from 'src/components/Input'; import AsyncSelect from 'src/components/Select/AsyncSelect'; diff --git a/superset-frontend/src/features/roles/RoleFormItems.tsx b/superset-frontend/src/features/roles/RoleFormItems.tsx new file mode 100644 index 0000000000000000000000000000000000000000..56c4ad76f4bf6a1f110b23d7721af045b8432bc6 --- /dev/null +++ b/superset-frontend/src/features/roles/RoleFormItems.tsx @@ -0,0 +1,70 @@ +/** + * 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 { FormItem } from 'src/components/Form'; +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'; + +interface PermissionsFieldProps { + permissions: FormattedPermission[]; +} + +interface UsersFieldProps { + users: UserObject[]; +} + +export const RoleNameField = () => ( + <FormItem + name="roleName" + label={t('Role Name')} + rules={[{ required: true, message: t('Role name is required') }]} + > + <Input name="roleName" data-test="role-name-input" /> + </FormItem> +); + +export const PermissionsField: FC<PermissionsFieldProps> = ({ + permissions, +}) => ( + <FormItem name="rolePermissions" label={t('Permissions')}> + <Select + mode="multiple" + name="rolePermissions" + options={permissions.map(permission => ({ + label: permission.label, + value: permission.id, + }))} + getPopupContainer={trigger => trigger.closest('.antd5-modal-content')} + data-test="permissions-select" + /> + </FormItem> +); + +export const UsersField: FC<UsersFieldProps> = ({ users }) => ( + <FormItem name="roleUsers" label={t('Users')}> + <Select + mode="multiple" + name="roleUsers" + options={users.map(user => ({ label: user.username, value: user.id }))} + data-test="users-select" + /> + </FormItem> +); diff --git a/superset-frontend/src/features/roles/RoleListAddModal.test.tsx b/superset-frontend/src/features/roles/RoleListAddModal.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..69d7eb66365c159e3780f2249454f715735169df --- /dev/null +++ b/superset-frontend/src/features/roles/RoleListAddModal.test.tsx @@ -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. + */ + +import { + render, + screen, + fireEvent, + waitFor, +} from 'spec/helpers/testing-library'; +import RoleListAddModal from './RoleListAddModal'; +import { createRole } from './utils'; + +const mockToasts = { + addDangerToast: jest.fn(), + addSuccessToast: jest.fn(), +}; + +jest.mock('./utils'); +const mockCreateRole = jest.mocked(createRole); + +jest.mock('src/components/MessageToasts/withToasts', () => ({ + useToasts: () => mockToasts, +})); + +describe('RoleListAddModal', () => { + const mockProps = { + show: true, + onHide: jest.fn(), + onSave: jest.fn(), + permissions: [ + { id: 1, label: 'Permission 1', value: 'Permission_1' }, + { id: 2, label: 'Permission 2', value: 'Permission_2' }, + ], + }; + + it('renders modal with form fields', () => { + render(<RoleListAddModal {...mockProps} />); + expect(screen.getByText('Add Role')).toBeInTheDocument(); + expect(screen.getByText('Role Name')).toBeInTheDocument(); + expect(screen.getByText('Permissions')).toBeInTheDocument(); + }); + + it('calls onHide when cancel button is clicked', () => { + render(<RoleListAddModal {...mockProps} />); + fireEvent.click(screen.getByTestId('modal-cancel-button')); + expect(mockProps.onHide).toHaveBeenCalled(); + }); + + it('disables save button when role name is empty', () => { + render(<RoleListAddModal {...mockProps} />); + expect(screen.getByTestId('form-modal-save-button')).toBeDisabled(); + }); + + it('enables save button when role name is entered', () => { + render(<RoleListAddModal {...mockProps} />); + fireEvent.change(screen.getByTestId('role-name-input'), { + target: { value: 'New Role' }, + }); + expect(screen.getByTestId('form-modal-save-button')).toBeEnabled(); + }); + + it('calls createRole when save button is clicked', async () => { + render(<RoleListAddModal {...mockProps} />); + + fireEvent.change(screen.getByTestId('role-name-input'), { + target: { value: 'New Role' }, + }); + + const saveButton = screen.getByRole('button', { name: 'Save' }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockCreateRole).toHaveBeenCalledWith('New Role'); + }); + }); +}); diff --git a/superset-frontend/src/features/roles/RoleListAddModal.tsx b/superset-frontend/src/features/roles/RoleListAddModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c80def6724a164efcd9e8ce13c823eab5d968c34 --- /dev/null +++ b/superset-frontend/src/features/roles/RoleListAddModal.tsx @@ -0,0 +1,71 @@ +/** + * 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 { createRole, updateRolePermissions } from './utils'; +import { PermissionsField, RoleNameField } from './RoleFormItems'; +import { BaseModalProps, FormattedPermission, RoleForm } from './types'; + +export interface RoleListAddModalProps extends BaseModalProps { + permissions: FormattedPermission[]; +} + +function RoleListAddModal({ + show, + onHide, + onSave, + permissions, +}: RoleListAddModalProps) { + const { addDangerToast, addSuccessToast } = useToasts(); + + const handleFormSubmit = async (values: RoleForm) => { + try { + const { json: roleResponse } = await createRole(values.roleName); + + if (values.rolePermissions?.length > 0) { + await updateRolePermissions(roleResponse.id, values.rolePermissions); + } + + addSuccessToast(t('Role was successfully created!')); + } catch (err) { + addDangerToast(t('Error while adding role!')); + throw err; + } + }; + + return ( + <FormModal + show={show} + onHide={onHide} + title={t('Add Role')} + onSave={onSave} + formSubmitHandler={handleFormSubmit} + requiredFields={['roleName']} + initialValues={{}} + > + <> + <RoleNameField /> + <PermissionsField permissions={permissions} /> + </> + </FormModal> + ); +} + +export default RoleListAddModal; diff --git a/superset-frontend/src/features/roles/RoleListDuplicateModal.test.tsx b/superset-frontend/src/features/roles/RoleListDuplicateModal.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..21c56b9b30999301ba61d37f7a2d7c0d4e06ef54 --- /dev/null +++ b/superset-frontend/src/features/roles/RoleListDuplicateModal.test.tsx @@ -0,0 +1,100 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + render, + screen, + fireEvent, + waitFor, +} from 'spec/helpers/testing-library'; +import RoleListDuplicateModal from './RoleListDuplicateModal'; +import { createRole, updateRolePermissions } from './utils'; + +const mockToasts = { + addDangerToast: jest.fn(), + addSuccessToast: jest.fn(), +}; + +jest.mock('./utils'); +const mockCreateRole = jest.mocked(createRole); +const mockUpdateRolePermissions = jest.mocked(updateRolePermissions); + +jest.mock('src/components/MessageToasts/withToasts', () => ({ + useToasts: () => mockToasts, +})); + +describe('RoleListDuplicateModal', () => { + const mockRole = { + id: 1, + name: 'Admin', + permission_ids: [10, 20], + user_ids: [1], + }; + + const mockProps = { + role: mockRole, + show: true, + onHide: jest.fn(), + onSave: jest.fn(), + }; + + it('renders modal with form fields', () => { + render(<RoleListDuplicateModal {...mockProps} />); + expect( + screen.getByText(`Duplicate role ${mockRole.name}`), + ).toBeInTheDocument(); + expect(screen.getByText('Role Name')).toBeInTheDocument(); + }); + + it('calls onHide when cancel button is clicked', () => { + render(<RoleListDuplicateModal {...mockProps} />); + fireEvent.click(screen.getByTestId('modal-cancel-button')); + expect(mockProps.onHide).toHaveBeenCalled(); + }); + + it('disables save button when role name is empty', () => { + render(<RoleListDuplicateModal {...mockProps} />); + expect(screen.getByTestId('form-modal-save-button')).toBeDisabled(); + }); + + it('enables save button when role name is entered', () => { + render(<RoleListDuplicateModal {...mockProps} />); + fireEvent.change(screen.getByTestId('role-name-input'), { + target: { value: 'New Role' }, + }); + expect(screen.getByTestId('form-modal-save-button')).toBeEnabled(); + }); + + it('calls createRole when save button is clicked', async () => { + mockCreateRole.mockResolvedValue({ json: { id: 2 } } as any); + + render(<RoleListDuplicateModal {...mockProps} />); + + fireEvent.change(screen.getByTestId('role-name-input'), { + target: { value: 'New Role' }, + }); + + fireEvent.click(screen.getByTestId('form-modal-save-button')); + + await waitFor(() => { + expect(mockCreateRole).toHaveBeenCalledWith('New Role'); + expect(mockUpdateRolePermissions).toHaveBeenCalledWith(2, [10, 20]); + }); + }); +}); diff --git a/superset-frontend/src/features/roles/RoleListDuplicateModal.tsx b/superset-frontend/src/features/roles/RoleListDuplicateModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f7d6b7a19d03fa10762de8b0a8d464527b850ca5 --- /dev/null +++ b/superset-frontend/src/features/roles/RoleListDuplicateModal.tsx @@ -0,0 +1,69 @@ +/** + * 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 { RoleObject } from 'src/pages/RolesList'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import FormModal from 'src/components/Modal/FormModal'; +import { RoleNameField } from './RoleFormItems'; +import { BaseModalProps, RoleForm } from './types'; +import { createRole, updateRolePermissions } from './utils'; + +export interface RoleListDuplicateModalProps extends BaseModalProps { + role: RoleObject; +} + +function RoleListDuplicateModal({ + role, + show, + onHide, + onSave, +}: RoleListDuplicateModalProps) { + const { name, permission_ids } = role; + const { addDangerToast, addSuccessToast } = useToasts(); + + const handleFormSubmit = async (values: RoleForm) => { + try { + const { json: roleResponse } = await createRole(values.roleName); + + if (permission_ids.length > 0) { + await updateRolePermissions(roleResponse.id, permission_ids); + } + addSuccessToast(t('Role was successfully duplicated!')); + } catch (err) { + addDangerToast(t('Error while duplicating role!')); + throw err; + } + }; + + return ( + <FormModal + show={show} + onHide={onHide} + title={t('Duplicate role %(name)s', { name })} + onSave={onSave} + formSubmitHandler={handleFormSubmit} + requiredFields={['roleName']} + initialValues={{}} + > + <RoleNameField /> + </FormModal> + ); +} +export default RoleListDuplicateModal; diff --git a/superset-frontend/src/features/roles/RoleListEditModal.test.tsx b/superset-frontend/src/features/roles/RoleListEditModal.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8e8c17dc0a04a9409f2f2ef471b7d41012cc0b4f --- /dev/null +++ b/superset-frontend/src/features/roles/RoleListEditModal.test.tsx @@ -0,0 +1,153 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + render, + screen, + fireEvent, + waitFor, +} from 'spec/helpers/testing-library'; +import RoleListEditModal from './RoleListEditModal'; +import { + updateRoleName, + updateRolePermissions, + updateRoleUsers, +} from './utils'; + +const mockToasts = { + addDangerToast: jest.fn(), + addSuccessToast: jest.fn(), +}; + +jest.mock('./utils'); +const mockUpdateRoleName = jest.mocked(updateRoleName); +const mockUpdateRolePermissions = jest.mocked(updateRolePermissions); +const mockUpdateRoleUsers = jest.mocked(updateRoleUsers); + +jest.mock('src/components/MessageToasts/withToasts', () => ({ + useToasts: () => mockToasts, +})); + +describe('RoleListEditModal', () => { + const mockRole = { + id: 1, + name: 'Admin', + permission_ids: [10, 20], + user_ids: [5, 7], + }; + + const mockPermissions = [ + { id: 10, label: 'Permission A', value: 'perm_a' }, + { id: 20, label: 'Permission B', value: 'perm_b' }, + ]; + + const mockUsers = [ + { + id: 5, + firstName: 'John', + lastName: 'Doe', + username: 'johndoe', + email: 'john@example.com', + isActive: true, + roles: [ + { + id: 1, + name: 'Admin', + }, + ], + }, + ]; + + const mockProps = { + role: mockRole, + show: true, + onHide: jest.fn(), + onSave: jest.fn(), + permissions: mockPermissions, + users: mockUsers, + }; + + it('renders modal with correct title and fields', () => { + render(<RoleListEditModal {...mockProps} />); + expect(screen.getAllByText('Edit Role')[0]).toBeInTheDocument(); + expect(screen.getByText('Role Name')).toBeInTheDocument(); + expect(screen.getByText('Permissions')).toBeInTheDocument(); + expect(screen.getAllByText('Users')[0]).toBeInTheDocument(); + }); + + it('calls onHide when cancel button is clicked', () => { + render(<RoleListEditModal {...mockProps} />); + fireEvent.click(screen.getByTestId('modal-cancel-button')); + expect(mockProps.onHide).toHaveBeenCalled(); + }); + + it('disables save button when role name is empty', () => { + render(<RoleListEditModal {...mockProps} />); + fireEvent.change(screen.getByTestId('role-name-input'), { + target: { value: '' }, + }); + expect(screen.getByTestId('form-modal-save-button')).toBeDisabled(); + }); + + it('enables save button when role name is entered', () => { + render(<RoleListEditModal {...mockProps} />); + fireEvent.change(screen.getByTestId('role-name-input'), { + target: { value: 'Updated Role' }, + }); + expect(screen.getByTestId('form-modal-save-button')).toBeEnabled(); + }); + + it('calls update functions when save button is clicked', async () => { + render(<RoleListEditModal {...mockProps} />); + + fireEvent.change(screen.getByTestId('role-name-input'), { + target: { value: 'Updated Role' }, + }); + + fireEvent.click(screen.getByTestId('form-modal-save-button')); + + await waitFor(() => { + expect(mockUpdateRoleName).toHaveBeenCalledWith( + mockRole.id, + 'Updated Role', + ); + expect(mockUpdateRolePermissions).toHaveBeenCalledWith( + mockRole.id, + mockRole.permission_ids, + ); + expect(mockUpdateRoleUsers).toHaveBeenCalledWith( + mockRole.id, + mockRole.user_ids, + ); + expect(mockProps.onSave).toHaveBeenCalled(); + }); + }); + + it('switches tabs correctly', () => { + render(<RoleListEditModal {...mockProps} />); + + const usersTab = screen.getByRole('tab', { name: 'Users' }); + fireEvent.click(usersTab); + + expect(screen.getByText('First Name')).toBeInTheDocument(); + expect(screen.getByText('Last Name')).toBeInTheDocument(); + expect(screen.getByText('User Name')).toBeInTheDocument(); + expect(screen.getByText('Email')).toBeInTheDocument(); + }); +}); diff --git a/superset-frontend/src/features/roles/RoleListEditModal.tsx b/superset-frontend/src/features/roles/RoleListEditModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e22f950d5e2a5eef507734e0575997b37e51f812 --- /dev/null +++ b/superset-frontend/src/features/roles/RoleListEditModal.tsx @@ -0,0 +1,153 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useState } from 'react'; +import { t } from '@superset-ui/core'; +import Tabs from 'src/components/Tabs'; +import { RoleObject } from 'src/pages/RolesList'; +import TableView, { EmptyWrapperType } from 'src/components/TableView'; +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 { + updateRoleName, + updateRolePermissions, + updateRoleUsers, +} from './utils'; + +export interface RoleListEditModalProps extends BaseModalProps { + role: RoleObject; + permissions: FormattedPermission[]; + users: UserObject[]; +} + +const roleTabs = { + edit: { + key: 'edit', + name: t('Edit Role'), + }, + users: { + key: 'users', + name: t('Users'), + }, +}; + +const userColumns = [ + { + accessor: 'first_name', + Header: 'First Name', + }, + { + accessor: 'last_name', + Header: 'Last Name', + }, + { + accessor: 'username', + Header: 'User Name', + }, + { + accessor: 'email', + Header: 'Email', + }, + { + accessor: 'active', + Header: 'Is Active?', + Cell: ({ cell }: CellProps<{ active: boolean }>) => + cell.value ? 'Yes' : 'No', + }, +]; + +function RoleListEditModal({ + show, + onHide, + role, + onSave, + permissions, + users, +}: RoleListEditModalProps) { + const { id, name, permission_ids, user_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 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!')); + } catch (err) { + addDangerToast(t('Error while updating role!')); + throw err; + } + }; + + const initialValues = { + roleName: name, + rolePermissions: permission_ids, + roleUsers: user_ids, + }; + + return ( + <FormModal + show={show} + onHide={onHide} + title={t('Edit Role')} + onSave={onSave} + formSubmitHandler={handleFormSubmit} + initialValues={initialValues} + 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> + </FormModal> + ); +} + +export default RoleListEditModal; diff --git a/superset-frontend/src/components/IconButton/IconButton.test.jsx b/superset-frontend/src/features/roles/types.ts similarity index 51% rename from superset-frontend/src/components/IconButton/IconButton.test.jsx rename to superset-frontend/src/features/roles/types.ts index aa37d8949091d5d7ffe2e2c8074e14d127a38512..d97a8dad611bcd11e50d8f4f3275d9d663cee745 100644 --- a/superset-frontend/src/components/IconButton/IconButton.test.jsx +++ b/superset-frontend/src/features/roles/types.ts @@ -16,22 +16,51 @@ * specific language governing permissions and limitations * under the License. */ -import { render, screen } from 'spec/helpers/testing-library'; -import IconButton from 'src/components/IconButton'; +export type PermissionView = { + name: string; +}; -const defaultProps = { - buttonText: 'This is the IconButton text', - icon: '/images/icons/sql.svg', +export type PermissionResource = { + id: number; + permission: PermissionView; + view_menu: PermissionView; }; -describe('IconButton', () => { - it('renders an IconButton', () => { - render(<IconButton {...defaultProps} />); +export type FormattedPermission = { + label: string; + value: string; + id: number; +}; - const icon = screen.getByRole('img'); - const buttonText = screen.getByText(/this is the iconbutton text/i); +export type RolePermissions = { + id: number; + permission_name: string; + view_menu_name: string; +}; + +export type UserObject = { + id: number; + firstName: string; + lastName: string; + username: string; + email: string; + isActive: boolean; + roles: Array<RoleInfo>; +}; + +export type RoleInfo = { + id: number; + name: string; +}; + +export type RoleForm = { + roleName: string; + rolePermissions: number[]; + roleUsers: number[]; +}; - expect(icon).toBeVisible(); - expect(buttonText).toBeVisible(); - }); -}); +export interface BaseModalProps { + show: boolean; + onHide: () => void; + onSave: () => void; +} diff --git a/superset-frontend/src/features/roles/utils.ts b/superset-frontend/src/features/roles/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..fcee8e6463330ae361320e00e348508607d7238d --- /dev/null +++ b/superset-frontend/src/features/roles/utils.ts @@ -0,0 +1,47 @@ +/** + * 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 } from '@superset-ui/core'; + +export const createRole = async (name: string) => + SupersetClient.post({ + endpoint: '/api/v1/security/roles/', + jsonPayload: { name }, + }); + +export const updateRolePermissions = async ( + roleId: number, + permissionIds: number[], +) => + SupersetClient.post({ + endpoint: `/api/v1/security/roles/${roleId}/permissions`, + jsonPayload: { permission_view_menu_ids: permissionIds }, + }); + +export const updateRoleUsers = async (roleId: number, userIds: number[]) => + SupersetClient.put({ + endpoint: `/api/v1/security/roles/${roleId}/users`, + jsonPayload: { user_ids: userIds }, + }); + +export const updateRoleName = async (roleId: number, name: string) => + SupersetClient.put({ + endpoint: `/api/v1/security/roles/${roleId}`, + jsonPayload: { name }, + }); diff --git a/superset-frontend/src/features/tags/TagCard.tsx b/superset-frontend/src/features/tags/TagCard.tsx index 0bf1008f464f3163911fdba3675e6b5559c246dc..2da403bc7205e68e3d6d18146eb95e78f72d1f47 100644 --- a/superset-frontend/src/features/tags/TagCard.tsx +++ b/superset-frontend/src/features/tags/TagCard.tsx @@ -23,7 +23,7 @@ import { Dropdown } from 'src/components/Dropdown'; import { Menu } from 'src/components/Menu'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import ListViewCard from 'src/components/ListViewCard'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { Tag } from 'src/views/CRUD/types'; import { deleteTags } from 'src/features/tags/tags'; import { Button } from 'src/components'; diff --git a/superset-frontend/src/middleware/logger.test.js b/superset-frontend/src/middleware/logger.test.js index 629940b969eb7d19be007176a23887e59576aeb5..0a28d42fdef688f88857ddc640ef56478dc0ce4c 100644 --- a/superset-frontend/src/middleware/logger.test.js +++ b/superset-frontend/src/middleware/logger.test.js @@ -20,7 +20,10 @@ import sinon from 'sinon'; import { SupersetClient } from '@superset-ui/core'; import logger from 'src/middleware/loggerMiddleware'; import { LOG_EVENT } from 'src/logger/actions'; -import { LOG_ACTIONS_LOAD_CHART } from 'src/logger/LogUtils'; +import { + LOG_ACTIONS_LOAD_CHART, + LOG_ACTIONS_SPA_NAVIGATION, +} from 'src/logger/LogUtils'; describe('logger middleware', () => { const dashboardId = 123; @@ -40,7 +43,6 @@ describe('logger middleware', () => { eventData: { key: 'value', start_offset: 100, - path: `/dashboard/${dashboardId}/`, }, }, }; @@ -82,11 +84,19 @@ describe('logger middleware', () => { }); it('should include ts, start_offset, event_name, impression_id, source, and source_id in every event', () => { - logger(mockStore)(next)(action); + const fetchLog = logger(mockStore)(next); + fetchLog({ + type: LOG_EVENT, + payload: { + eventName: LOG_ACTIONS_SPA_NAVIGATION, + eventData: { path: `/dashboard/${dashboardId}/` }, + }, + }); timeSandbox.clock.tick(2000); - - expect(SupersetClient.post.callCount).toBe(1); - const { events } = SupersetClient.post.getCall(0).args[0].postPayload; + fetchLog(action); + timeSandbox.clock.tick(2000); + expect(SupersetClient.post.callCount).toBe(2); + const { events } = SupersetClient.post.getCall(1).args[0].postPayload; const mockEventdata = action.payload.eventData; const mockEventname = action.payload.eventName; expect(events[0]).toMatchObject({ diff --git a/superset-frontend/src/middleware/loggerMiddleware.js b/superset-frontend/src/middleware/loggerMiddleware.js index a3eb629571273cad2d5dc34f4c9185f9aada8757..c008d027b3bbde8b4b4e4109e50cf2d6c83276e2 100644 --- a/superset-frontend/src/middleware/loggerMiddleware.js +++ b/superset-frontend/src/middleware/loggerMiddleware.js @@ -23,7 +23,10 @@ import { SupersetClient } from '@superset-ui/core'; import { safeStringify } from '../utils/safeStringify'; import { LOG_EVENT } from '../logger/actions'; -import { LOG_EVENT_TYPE_TIMING } from '../logger/LogUtils'; +import { + LOG_EVENT_TYPE_TIMING, + LOG_ACTIONS_SPA_NAVIGATION, +} from '../logger/LogUtils'; import DebouncedMessageQueue from '../utils/DebouncedMessageQueue'; const LOG_ENDPOINT = '/superset/log/?explode=events'; @@ -67,78 +70,87 @@ const logMessageQueue = new DebouncedMessageQueue({ delayThreshold: 1000, }); let lastEventId = 0; -const loggerMiddleware = store => next => action => { - if (action.type !== LOG_EVENT) { - return next(action); - } +const loggerMiddleware = store => next => { + let navPath; + return action => { + if (action.type !== LOG_EVENT) { + return next(action); + } - const { dashboardInfo, explore, impressionId, dashboardLayout, sqlLab } = - store.getState(); - let logMetadata = { - impression_id: impressionId, - version: 'v2', - }; - const { eventName } = action.payload; - let { eventData = {} } = action.payload; + const { dashboardInfo, explore, impressionId, dashboardLayout, sqlLab } = + store.getState(); + let logMetadata = { + impression_id: impressionId, + version: 'v2', + }; + const { eventName } = action.payload; + let { eventData = {} } = action.payload; - const path = eventData.path || window?.location?.href; + if (eventName === LOG_ACTIONS_SPA_NAVIGATION) { + navPath = eventData.path; + } + const path = navPath || window?.location?.href; - if (dashboardInfo?.id && path?.includes('/dashboard/')) { - logMetadata = { - source: 'dashboard', - source_id: dashboardInfo.id, - dashboard_id: dashboardInfo.id, - ...logMetadata, - }; - } else if (explore?.slice) { - logMetadata = { - source: 'explore', - source_id: explore.slice ? explore.slice.slice_id : 0, - ...(explore.slice.slice_id && { slice_id: explore.slice.slice_id }), - ...logMetadata, - }; - } else if (path?.includes('/sqllab/')) { - const editor = sqlLab.queryEditors.find( - ({ id }) => id === sqlLab.tabHistory.slice(-1)[0], - ); - logMetadata = { - source: 'sqlLab', - source_id: editor?.id, - db_id: editor?.dbId, - schema: editor?.schema, - }; - } + if (dashboardInfo?.id && path?.includes('/dashboard/')) { + logMetadata = { + source: 'dashboard', + source_id: dashboardInfo.id, + dashboard_id: dashboardInfo.id, + ...logMetadata, + }; + } else if (explore?.slice) { + logMetadata = { + source: 'explore', + source_id: explore.slice ? explore.slice.slice_id : 0, + ...(explore.slice.slice_id && { slice_id: explore.slice.slice_id }), + ...logMetadata, + }; + } else if (path?.includes('/sqllab/')) { + const editor = sqlLab.queryEditors.find( + ({ id }) => id === sqlLab.tabHistory.slice(-1)[0], + ); + logMetadata = { + source: 'sqlLab', + source_id: editor?.id, + db_id: editor?.dbId, + schema: editor?.schema, + }; + } - eventData = { - ...logMetadata, - ts: new Date().getTime(), - event_name: eventName, - ...eventData, - }; - if (LOG_EVENT_TYPE_TIMING.has(eventName)) { - eventData = { - ...eventData, - event_type: 'timing', - trigger_event: lastEventId, - }; - } else { - lastEventId = nanoid(); eventData = { + ...logMetadata, + ts: new Date().getTime(), + event_name: eventName, ...eventData, - event_type: 'user', - event_id: lastEventId, - visibility: document.visibilityState, }; - } + if (LOG_EVENT_TYPE_TIMING.has(eventName)) { + eventData = { + ...eventData, + event_type: 'timing', + trigger_event: lastEventId, + }; + } else { + lastEventId = nanoid(); + eventData = { + ...eventData, + event_type: 'user', + event_id: lastEventId, + visibility: document.visibilityState, + }; + } - if (eventData.target_id && dashboardLayout?.present?.[eventData.target_id]) { - const { meta } = dashboardLayout.present[eventData.target_id]; - // chart name or tab/header text - eventData.target_name = meta.chartId ? meta.sliceName : meta.text; - } + if ( + eventData.target_id && + dashboardLayout?.present?.[eventData.target_id] + ) { + const { meta } = dashboardLayout.present[eventData.target_id]; + // chart name or tab/header text + eventData.target_name = meta.chartId ? meta.sliceName : meta.text; + } - logMessageQueue.append(eventData); - return eventData; + logMessageQueue.append(eventData); + return eventData; + }; }; export default loggerMiddleware; diff --git a/superset-frontend/src/pages/AlertReportList/index.tsx b/superset-frontend/src/pages/AlertReportList/index.tsx index c33743fb0c2431d4c240a21248c7563de71d17e3..2d24444d2404a797458bed1a7404b702688cccc2 100644 --- a/superset-frontend/src/pages/AlertReportList/index.tsx +++ b/superset-frontend/src/pages/AlertReportList/index.tsx @@ -57,7 +57,7 @@ import AlertReportModal from 'src/features/alerts/AlertReportModal'; import { AlertObject, AlertState } from 'src/features/alerts/types'; import { ModifiedInfo } from 'src/components/AuditInfo'; import { QueryObjectColumns } from 'src/views/CRUD/types'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; const extensionsRegistry = getExtensionsRegistry(); diff --git a/superset-frontend/src/pages/AnnotationLayerList/index.tsx b/superset-frontend/src/pages/AnnotationLayerList/index.tsx index 652dff17632efb3093590e28817fd8976cb96ac5..253d33c0d332637d1a60476418b0301be4da8db3 100644 --- a/superset-frontend/src/pages/AnnotationLayerList/index.tsx +++ b/superset-frontend/src/pages/AnnotationLayerList/index.tsx @@ -37,7 +37,7 @@ import AnnotationLayerModal from 'src/features/annotationLayers/AnnotationLayerM import { AnnotationLayerObject } from 'src/features/annotationLayers/types'; import { ModifiedInfo } from 'src/components/AuditInfo'; import { QueryObjectColumns } from 'src/views/CRUD/types'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; const PAGE_SIZE = 25; diff --git a/superset-frontend/src/pages/AnnotationList/index.tsx b/superset-frontend/src/pages/AnnotationList/index.tsx index 171204e9cd095d4b5d727169c0de5eddef56c6b0..70a41024f6d01f9ac806c248db55fa9042e75b10 100644 --- a/superset-frontend/src/pages/AnnotationList/index.tsx +++ b/superset-frontend/src/pages/AnnotationList/index.tsx @@ -41,7 +41,7 @@ import { createErrorHandler } from 'src/views/CRUD/utils'; import { AnnotationObject } from 'src/features/annotations/types'; import AnnotationModal from 'src/features/annotations/AnnotationModal'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; const PAGE_SIZE = 25; diff --git a/superset-frontend/src/pages/ChartCreation/index.tsx b/superset-frontend/src/pages/ChartCreation/index.tsx index 17fa9908e13342da5a6f5dbf5e0e57a12a15060e..77e86a3b99cf67e8c42f91893cc3ede74a9502e8 100644 --- a/superset-frontend/src/pages/ChartCreation/index.tsx +++ b/superset-frontend/src/pages/ChartCreation/index.tsx @@ -45,7 +45,7 @@ import { Dataset, DatasetSelectLabel, } from 'src/features/datasets/DatasetSelectLabel'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; export interface ChartCreationProps extends RouteComponentProps { user: UserWithPermissionsAndRoles; diff --git a/superset-frontend/src/pages/ChartList/index.tsx b/superset-frontend/src/pages/ChartList/index.tsx index b2158b09a2b18a018366a02a9025c7458783c81c..197e0a425f242b47f91caa489c9d9d94dc16a043 100644 --- a/superset-frontend/src/pages/ChartList/index.tsx +++ b/superset-frontend/src/pages/ChartList/index.tsx @@ -62,7 +62,7 @@ import ImportModelsModal from 'src/components/ImportModal/index'; import Chart from 'src/types/Chart'; import Tag from 'src/types/TagType'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { nativeFilterGate } from 'src/dashboard/components/nativeFilters/utils'; import InfoTooltip from 'src/components/InfoTooltip'; import CertifiedBadge from 'src/components/CertifiedBadge'; diff --git a/superset-frontend/src/pages/CssTemplateList/index.tsx b/superset-frontend/src/pages/CssTemplateList/index.tsx index e1853ad89edf514541ef6376e23aa09f48af7809..13ad661c45c689c215418b05d6d04dc64b7a745b 100644 --- a/superset-frontend/src/pages/CssTemplateList/index.tsx +++ b/superset-frontend/src/pages/CssTemplateList/index.tsx @@ -37,7 +37,7 @@ import CssTemplateModal from 'src/features/cssTemplates/CssTemplateModal'; import { TemplateObject } from 'src/features/cssTemplates/types'; import { ModifiedInfo } from 'src/components/AuditInfo'; import { QueryObjectColumns } from 'src/views/CRUD/types'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; const PAGE_SIZE = 25; diff --git a/superset-frontend/src/pages/DashboardList/index.tsx b/superset-frontend/src/pages/DashboardList/index.tsx index 3543cde0f00e435f72f6df66ca69442d1e111133..2331dffdfc0b468e278180eb19540da6a5b1d23b 100644 --- a/superset-frontend/src/pages/DashboardList/index.tsx +++ b/superset-frontend/src/pages/DashboardList/index.tsx @@ -52,7 +52,7 @@ import Owner from 'src/types/Owner'; import Tag from 'src/types/TagType'; import withToasts from 'src/components/MessageToasts/withToasts'; import FacePile from 'src/components/FacePile'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import DeleteModal from 'src/components/DeleteModal'; import FaveStar from 'src/components/FaveStar'; import PropertiesModal from 'src/dashboard/components/PropertiesModal'; diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx index 3d15141d71f39bcb40525db2fb353353252ac37a..c1a83dc8f141e9075d31a0b3534ca13555c27ba1 100644 --- a/superset-frontend/src/pages/DatabaseList/index.tsx +++ b/superset-frontend/src/pages/DatabaseList/index.tsx @@ -42,7 +42,7 @@ import DeleteModal from 'src/components/DeleteModal'; import { getUrlParam } from 'src/utils/urlUtils'; import { URL_PARAMS } from 'src/constants'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; import ListView, { FilterOperator, Filters } from 'src/components/ListView'; import handleResourceExport from 'src/utils/export'; diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx index 5ae4bdb5ee60f704288b3e01d26c0c524daf48f6..fbd07fb236463bd87ce4deadc10527a05c4ea5c5 100644 --- a/superset-frontend/src/pages/DatasetList/index.tsx +++ b/superset-frontend/src/pages/DatasetList/index.tsx @@ -49,7 +49,7 @@ import SubMenu, { SubMenuProps, ButtonProps } from 'src/features/home/SubMenu'; import Owner from 'src/types/Owner'; import withToasts from 'src/components/MessageToasts/withToasts'; import { Tooltip } from 'src/components/Tooltip'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import FacePile from 'src/components/FacePile'; import CertifiedBadge from 'src/components/CertifiedBadge'; import InfoTooltip from 'src/components/InfoTooltip'; diff --git a/superset-frontend/src/pages/QueryHistoryList/index.tsx b/superset-frontend/src/pages/QueryHistoryList/index.tsx index 20d610e27dda8845c62593cbc4263a1c598255af..3986291aeaa8fae29206572369b96881729fff9c 100644 --- a/superset-frontend/src/pages/QueryHistoryList/index.tsx +++ b/superset-frontend/src/pages/QueryHistoryList/index.tsx @@ -50,7 +50,7 @@ import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github'; import { DATETIME_WITH_TIME_ZONE, TIME_WITH_MS } from 'src/constants'; import { QueryObject, QueryObjectColumns } from 'src/views/CRUD/types'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import QueryPreviewModal from 'src/features/queries/QueryPreviewModal'; import { addSuccessToast } from 'src/components/MessageToasts/actions'; import getOwnerName from 'src/utils/getOwnerName'; diff --git a/superset-frontend/src/pages/RolesList/RolesList.test.tsx b/superset-frontend/src/pages/RolesList/RolesList.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c4c4ee25541b65682eaa0cf2c73e9b92f4aef331 --- /dev/null +++ b/superset-frontend/src/pages/RolesList/RolesList.test.tsx @@ -0,0 +1,206 @@ +/** + * 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 RolesList from './index'; + +const mockStore = configureStore([thunk]); +const store = mockStore({}); + +const rolesEndpoint = 'glob:*/security/roles/search/?*'; +const roleEndpoint = 'glob:*/api/v1/security/roles/*'; +const permissionsEndpoint = 'glob:*/api/v1/security/permissions-resources/?*'; +const usersEndpoint = 'glob:*/api/v1/security/users/?*'; + +const mockRoles = [...new Array(3)].map((_, i) => ({ + id: i, + name: `role ${i}`, + user_ids: [i, i + 1], + permission_ids: [i, i + 1, i + 2], +})); + +const mockPermissions = [...new Array(10)].map((_, i) => ({ + id: i, + permission: { name: `permission_${i}` }, + view_menu: { name: `view_menu_${i}` }, +})); + +const mockUsers = [...new Array(5)].map((_, i) => ({ + id: i, + username: `user_${i}`, + first_name: `User`, + last_name: `${i}`, + roles: [ + { + id: 1, + }, + ], +})); + +const mockUser = { + userId: 1, + firstName: 'Admin', + lastName: 'User', + roles: [ + { + id: 1, + name: 'Admin', + }, + ], +}; + +jest.mock('src/dashboard/util/permissionUtils', () => ({ + ...jest.requireActual('src/dashboard/util/permissionUtils'), + isUserAdmin: jest.fn(() => true), +})); + +fetchMock.get(rolesEndpoint, { + ids: [2, 0, 1], + result: mockRoles, + count: 3, +}); + +fetchMock.get(permissionsEndpoint, { + count: mockPermissions.length, + result: mockPermissions, +}); + +fetchMock.get(usersEndpoint, { + count: mockUsers.length, + result: mockUsers, +}); + +fetchMock.delete(roleEndpoint, {}); +fetchMock.put(roleEndpoint, {}); + +describe('RolesList', () => { + async function renderAndWait() { + const mounted = act(async () => { + const mockedProps = {}; + render( + <MemoryRouter> + <QueryParamProvider> + <RolesList + user={mockUser} + addDangerToast={() => {}} + addSuccessToast={() => {}} + {...mockedProps} + /> + </QueryParamProvider> + </MemoryRouter>, + { useRedux: true, store }, + ); + }); + return mounted; + } + beforeEach(() => { + fetchMock.resetHistory(); + }); + + it('renders', async () => { + await renderAndWait(); + expect(await screen.findByText('List Roles')).toBeInTheDocument(); + }); + + it('fetches roles on load', async () => { + await renderAndWait(); + await waitFor(() => { + const calls = fetchMock.calls(rolesEndpoint); + expect(calls.length).toBeGreaterThan(0); + }); + }); + + it('fetches permissions and users 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); + }); + }); + + it('renders filters options', async () => { + await renderAndWait(); + + const typeFilter = screen.queryAllByTestId('filters-select'); + expect(typeFilter).toHaveLength(3); + }); + + it('renders correct list columns', async () => { + await renderAndWait(); + + const table = screen.getByRole('table'); + expect(table).toBeInTheDocument(); + + const nameColumn = await within(table).findByText('Name'); + const actionsColumn = await within(table).findByText('Actions'); + + expect(nameColumn).toBeInTheDocument(); + expect(actionsColumn).toBeInTheDocument(); + }); + + it('opens add modal when Add Role button is clicked', async () => { + await renderAndWait(); + + const addButton = screen.getByTestId('add-role-button'); + fireEvent.click(addButton); + + expect(screen.queryByTestId('Add Role-modal')).toBeInTheDocument(); + }); + + it('open duplicate modal when duplicate button is clicked', async () => { + await renderAndWait(); + + const table = screen.getByRole('table'); + expect(table).toBeInTheDocument(); + const duplicateAction = within(table).queryAllByTestId( + 'role-list-duplicate-action', + )[0]; + expect(duplicateAction).toBeInTheDocument(); + fireEvent.click(duplicateAction); + expect( + screen.queryByTestId('Duplicate role role 0-modal'), + ).toBeInTheDocument(); + }); + + it('open edit modal when edit button is clicked', async () => { + await renderAndWait(); + + const table = screen.getByRole('table'); + expect(table).toBeInTheDocument(); + const editAction = within(table).queryAllByTestId( + 'role-list-edit-action', + )[0]; + expect(editAction).toBeInTheDocument(); + fireEvent.click(editAction); + expect(screen.queryByTestId('Edit Role-modal')).toBeInTheDocument(); + }); +}); diff --git a/superset-frontend/src/pages/RolesList/index.tsx b/superset-frontend/src/pages/RolesList/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..be97427bd20ac4ccc4da1359a3fd2d801e5b8e16 --- /dev/null +++ b/superset-frontend/src/pages/RolesList/index.tsx @@ -0,0 +1,507 @@ +/** + * 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, SupersetClient, useTheme } from '@superset-ui/core'; +import { useListViewResource } from 'src/views/CRUD/hooks'; +import RoleListAddModal from 'src/features/roles/RoleListAddModal'; +import RoleListEditModal from 'src/features/roles/RoleListEditModal'; +import RoleListDuplicateModal from 'src/features/roles/RoleListDuplicateModal'; +import withToasts from 'src/components/MessageToasts/withToasts'; +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 { + FormattedPermission, + PermissionResource, + UserObject, +} from 'src/features/roles/types'; +import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; +import { Icons } from 'src/components/Icons'; + +const PAGE_SIZE = 25; + +interface RolesListProps { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; + user: { + userId: string | number; + firstName: string; + lastName: string; + roles: object; + }; +} + +export type RoleObject = { + id: number; + name: string; + permission_ids: number[]; + users?: Array<UserObject>; + user_ids: number[]; +}; + +enum ModalType { + ADD = 'add', + EDIT = 'edit', + DUPLICATE = 'duplicate', +} + +function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) { + const theme = useTheme(); + const { + state: { + loading, + resourceCount: rolesCount, + resourceCollection: roles, + bulkSelectEnabled, + }, + fetchData, + refreshData, + toggleBulkSelect, + } = useListViewResource<RoleObject>( + 'security/roles/search', + t('Role'), + addDangerToast, + false, + ); + const [modalState, setModalState] = useState({ + edit: false, + add: false, + duplicate: false, + }); + const openModal = (type: ModalType) => + setModalState(prev => ({ ...prev, [type]: true })); + const closeModal = (type: ModalType) => + setModalState(prev => ({ ...prev, [type]: false })); + + const [currentRole, setCurrentRole] = useState<RoleObject | null>(null); + const [roleCurrentlyDeleting, setRoleCurrentlyDeleting] = + useState<RoleObject | null>(null); + const [permissions, setPermissions] = useState<FormattedPermission[]>([]); + const [users, setUsers] = useState<UserObject[]>([]); + const [loadingState, setLoadingState] = useState({ + permissions: true, + users: true, + }); + + const isAdmin = useMemo(() => isUserAdmin(user), [user]); + + const fetchPermissions = useCallback(async () => { + try { + const pageSize = 100; + + 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 })); + } + }, []); + + useEffect(() => { + fetchPermissions(); + }, [fetchPermissions]); + + useEffect(() => { + fetchUsers(); + }, [fetchUsers]); + + const handleRoleDelete = async ({ id, name }: RoleObject) => { + try { + await SupersetClient.delete({ + endpoint: `/api/v1/security/roles/${id}`, + }); + + refreshData(); + setRoleCurrentlyDeleting(null); + addSuccessToast(t('Deleted role: %s', name)); + } catch (error) { + addDangerToast(t('There was an issue deleting %s', name)); + } + }; + + const handleBulkRolesDelete = async (rolesToDelete: RoleObject[]) => { + const deletedRoleNames: string[] = []; + + await Promise.all( + rolesToDelete.map(async role => { + try { + await SupersetClient.delete({ + endpoint: `api/v1/security/roles/${role.id}`, + }); + + deletedRoleNames.push(role.name); + } catch (error) { + addDangerToast(t('Error deleting %s', role.name)); + } + }), + ); + + if (deletedRoleNames.length > 0) { + addSuccessToast(t('Deleted roles: %s', deletedRoleNames.join(', '))); + } + + refreshData(); + }; + + const initialSort = [{ id: 'name', desc: true }]; + const columns = useMemo( + () => [ + { + accessor: 'name', + Header: t('Name'), + Cell: ({ + row: { + original: { name }, + }, + }: any) => <span>{name}</span>, + }, + { + accessor: 'user_ids', + Header: t('Users'), + hidden: true, + Cell: ({ row: { original } }: any) => original.user_ids.join(', '), + }, + { + accessor: 'permission_ids', + Header: t('Permissions'), + hidden: true, + Cell: ({ row: { original } }: any) => + original.permission_ids.join(', '), + }, + { + Cell: ({ row: { original } }: any) => { + const handleEdit = () => { + setCurrentRole(original); + openModal(ModalType.EDIT); + }; + const handleDelete = () => setRoleCurrentlyDeleting(original); + const handleDuplicate = () => { + setCurrentRole(original); + openModal(ModalType.DUPLICATE); + }; + + const actions = isAdmin + ? [ + { + label: 'role-list-edit-action', + tooltip: t('Edit role'), + placement: 'bottom', + icon: 'EditOutlined', + onClick: handleEdit, + }, + { + label: 'role-list-duplicate-action', + tooltip: t('Duplicate role'), + placement: 'bottom', + icon: 'CopyOutlined', + onClick: handleDuplicate, + }, + { + label: 'role-list-delete-action', + tooltip: t('Delete role'), + 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('Role')} + </> + ), + buttonStyle: 'primary', + onClick: () => { + openModal(ModalType.ADD); + }, + loading: loadingState.permissions, + 'data-test': 'add-role-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('Users'), + key: 'user_ids', + id: 'user_ids', + input: 'select', + operator: FilterOperator.RelationOneMany, + unfilteredLabel: t('All'), + selects: users?.map(user => ({ + label: user.username, + value: user.id, + })), + loading: loadingState.users, + }, + { + Header: t('Permissions'), + key: 'permission_ids', + id: 'permission_ids', + input: 'select', + operator: FilterOperator.RelationOneMany, + unfilteredLabel: t('All'), + selects: permissions?.map(permission => ({ + label: permission.label, + value: permission.id, + })), + loading: loadingState.permissions, + }, + ], + [permissions, users, loadingState.users, loadingState.permissions], + ); + + const emptyState = { + title: t('No roles 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('Role')} + </> + ), + }), + }; + + return ( + <> + <SubMenu name={t('List Roles')} buttons={subMenuButtons} /> + <RoleListAddModal + onHide={() => closeModal(ModalType.ADD)} + show={modalState.add} + onSave={() => { + refreshData(); + closeModal(ModalType.ADD); + }} + permissions={permissions} + /> + {modalState.edit && currentRole && ( + <RoleListEditModal + role={currentRole} + show={modalState.edit} + onHide={() => closeModal(ModalType.EDIT)} + onSave={() => { + refreshData(); + closeModal(ModalType.EDIT); + fetchUsers(); + }} + permissions={permissions} + users={users} + /> + )} + {modalState.duplicate && currentRole && ( + <RoleListDuplicateModal + role={currentRole} + show={modalState.duplicate} + onHide={() => closeModal(ModalType.DUPLICATE)} + onSave={() => { + refreshData(); + closeModal(ModalType.DUPLICATE); + }} + /> + )} + {roleCurrentlyDeleting && ( + <DeleteModal + description={t('This action will permanently delete the role.')} + onConfirm={() => { + if (roleCurrentlyDeleting) { + handleRoleDelete(roleCurrentlyDeleting); + } + }} + onHide={() => setRoleCurrentlyDeleting(null)} + open + title={t('Delete Role?')} + /> + )} + <ConfirmStatusChange + title={t('Please confirm')} + description={t('Are you sure you want to delete the selected roles?')} + onConfirm={handleBulkRolesDelete} + > + {confirmDelete => { + const bulkActions: ListViewProps['bulkActions'] = isAdmin + ? [ + { + key: 'delete', + name: t('Delete'), + onSelect: confirmDelete, + type: 'danger', + }, + ] + : []; + + return ( + <ListView<RoleObject> + className="role-list-view" + columns={columns} + count={rolesCount} + data={roles} + 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 withToasts(RolesList); diff --git a/superset-frontend/src/pages/RowLevelSecurityList/index.tsx b/superset-frontend/src/pages/RowLevelSecurityList/index.tsx index 8a2383772c6657250bc9f09a28b37ff0571d34c7..ac08540cf89b9c7129b3d5cc7980071de3eeaf7f 100644 --- a/superset-frontend/src/pages/RowLevelSecurityList/index.tsx +++ b/superset-frontend/src/pages/RowLevelSecurityList/index.tsx @@ -19,7 +19,7 @@ import { t, styled, SupersetClient, useTheme, css } from '@superset-ui/core'; import { useMemo, useState } from 'react'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import ListView, { FetchDataConfig, FilterOperator, diff --git a/superset-frontend/src/pages/SavedQueryList/index.tsx b/superset-frontend/src/pages/SavedQueryList/index.tsx index bfae50472104902b53000a97390ba131fd972472..9a4ca9df7a23821540cc52367f1dfa006bef746f 100644 --- a/superset-frontend/src/pages/SavedQueryList/index.tsx +++ b/superset-frontend/src/pages/SavedQueryList/index.tsx @@ -57,7 +57,7 @@ import Tag from 'src/types/TagType'; import ImportModelsModal from 'src/components/ImportModal/index'; import { ModifiedInfo } from 'src/components/AuditInfo'; import { loadTags } from 'src/components/Tags/utils'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import SavedQueryPreviewModal from 'src/features/queries/SavedQueryPreviewModal'; import { findPermission } from 'src/utils/findPermission'; diff --git a/superset-frontend/src/pages/Tags/index.tsx b/superset-frontend/src/pages/Tags/index.tsx index 95e782bd748348fe8833cdc3666a51073f42d189..448afad88f899f6b66d35140d51ec2ea7b1e615c 100644 --- a/superset-frontend/src/pages/Tags/index.tsx +++ b/superset-frontend/src/pages/Tags/index.tsx @@ -39,7 +39,7 @@ import ListView, { } from 'src/components/ListView'; import { dangerouslyGetItemDoNotUse } from 'src/utils/localStorageHelpers'; import withToasts from 'src/components/MessageToasts/withToasts'; -import Icons from 'src/components/Icons'; +import { Icons } from 'src/components/Icons'; import { Tooltip } from 'src/components/Tooltip'; import { Link } from 'react-router-dom'; import { deleteTags } from 'src/features/tags/tags'; diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index a13e1ca2d65d877b0d91fd53cfb69aa74adf8a04..7f4d363e2ccd346c03f9a98f97d69ec8f00325cc 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -18,6 +18,8 @@ */ import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import { lazy, ComponentType, ComponentProps } from 'react'; +import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; +import getBootstrapData from 'src/utils/getBootstrapData'; // not lazy loaded since this is the home page. import Home from 'src/pages/Home'; @@ -136,6 +138,10 @@ const MitMDatasetCreation = lazy( ), ); +const RolesList = lazy( + () => import(/* webpackChunkName: "RolesList" */ 'src/pages/RolesList'), +); + type Routes = { path: string; Component: ComponentType; @@ -251,6 +257,16 @@ if (isFeatureEnabled(FeatureFlag.TaggingSystem)) { }); } +const user = getBootstrapData()?.user; +const isAdmin = isUserAdmin(user); + +if (isAdmin) { + routes.push({ + path: '/roles/', + Component: RolesList, + }); +} + if (isFeatureEnabled(FeatureFlag.MITM_SUPPORT)) { console.log('Registering MitM routes') routes.push({ diff --git a/superset-websocket/package-lock.json b/superset-websocket/package-lock.json index 2c42942d8d57f350d832c4eb3a57a81a99934718..527407913363a2b4a2ec6cfa4db7314a315fc270 100644 --- a/superset-websocket/package-lock.json +++ b/superset-websocket/package-lock.json @@ -30,14 +30,14 @@ "@types/uuid": "^10.0.0", "@types/ws": "^8.5.12", "@typescript-eslint/eslint-plugin": "^8.26.0", - "@typescript-eslint/parser": "^8.6.0", + "@typescript-eslint/parser": "^8.29.0", "eslint": "^9.17.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-lodash": "^8.0.0", "globals": "^16.0.0", "jest": "^29.7.0", "prettier": "^3.4.2", - "ts-jest": "^29.2.5", + "ts-jest": "^29.3.1", "ts-node": "^10.9.2", "tscw-config": "^1.1.2", "typescript": "^5.6.2", @@ -2047,15 +2047,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.19.0.tgz", - "integrity": "sha512-6M8taKyOETY1TKHp0x8ndycipTVgmp4xtg5QpEZzXxDhNvvHOJi5rLRkLr8SK3jTgD5l4fTlvBiRdfsuWydxBw==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.0.tgz", + "integrity": "sha512-8C0+jlNJOwQso2GapCVWWfW/rzaq7Lbme+vGUFKE31djwNncIpgXD7Cd4weEsDdkoZDjH0lwwr3QDQFuyrMg9g==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.19.0", - "@typescript-eslint/types": "8.19.0", - "@typescript-eslint/typescript-estree": "8.19.0", - "@typescript-eslint/visitor-keys": "8.19.0", + "@typescript-eslint/scope-manager": "8.29.0", + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/typescript-estree": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0", "debug": "^4.3.4" }, "engines": { @@ -2067,7 +2068,136 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.29.0.tgz", + "integrity": "sha512-aO1PVsq7Gm+tcghabUpzEnVSFMCU4/nYIgC2GOatJcllvWfnhrgW0ZEbnTxm36QsikmCN1K/6ZgM7fok2I7xNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.29.0.tgz", + "integrity": "sha512-wcJL/+cOXV+RE3gjCyl/V2G877+2faqvlgtso/ZRbTCnZazh0gXhe+7gbAnfubzN2bNsBtZjDvlh7ero8uIbzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.29.0.tgz", + "integrity": "sha512-yOfen3jE9ISZR/hHpU/bmNvTtBW1NjRbkSFdZOksL1N+ybPEE7UVGMwqvS6CP022Rp00Sb0tdiIkhSCe6NI8ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.29.0.tgz", + "integrity": "sha512-Sne/pVz8ryR03NFK21VpN88dZ2FdQXOlq3VIklbrTYEt8yXtRFr9tvUhqvCeKjqYk5FSim37sHbooT6vzBTZcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.29.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" } }, "node_modules/@typescript-eslint/scope-manager": { @@ -6052,9 +6182,10 @@ } }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -6336,10 +6467,11 @@ } }, "node_modules/ts-jest": { - "version": "29.2.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.5.tgz", - "integrity": "sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==", + "version": "29.3.1", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.3.1.tgz", + "integrity": "sha512-FT2PIRtZABwl6+ZCry8IY7JZ3xMuppsEV9qFVHOVe8jDzggwUZ9TsM4chyJxL9yi6LvkqcZYU3LmapEE454zBQ==", "dev": true, + "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "ejs": "^3.1.10", @@ -6348,7 +6480,8 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.6.3", + "semver": "^7.7.1", + "type-fest": "^4.38.0", "yargs-parser": "^21.1.1" }, "bin": { @@ -6486,6 +6619,19 @@ "node": ">=4" } }, + "node_modules/type-fest": { + "version": "4.38.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.38.0.tgz", + "integrity": "sha512-2dBz5D5ycHIoliLYLi0Q2V7KRaDlH0uWIvmk7TYlAg5slqwiPv1ezJdZm1QEM0xgk29oYWMCbIG7E6gHpvChlg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", @@ -6551,6 +6697,31 @@ "typescript": ">=4.8.4 <5.8.0" } }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.19.0.tgz", + "integrity": "sha512-6M8taKyOETY1TKHp0x8ndycipTVgmp4xtg5QpEZzXxDhNvvHOJi5rLRkLr8SK3jTgD5l4fTlvBiRdfsuWydxBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.19.0", + "@typescript-eslint/types": "8.19.0", + "@typescript-eslint/typescript-estree": "8.19.0", + "@typescript-eslint/visitor-keys": "8.19.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.19.0.tgz", @@ -8413,16 +8584,91 @@ } }, "@typescript-eslint/parser": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.19.0.tgz", - "integrity": "sha512-6M8taKyOETY1TKHp0x8ndycipTVgmp4xtg5QpEZzXxDhNvvHOJi5rLRkLr8SK3jTgD5l4fTlvBiRdfsuWydxBw==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.0.tgz", + "integrity": "sha512-8C0+jlNJOwQso2GapCVWWfW/rzaq7Lbme+vGUFKE31djwNncIpgXD7Cd4weEsDdkoZDjH0lwwr3QDQFuyrMg9g==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "8.19.0", - "@typescript-eslint/types": "8.19.0", - "@typescript-eslint/typescript-estree": "8.19.0", - "@typescript-eslint/visitor-keys": "8.19.0", + "@typescript-eslint/scope-manager": "8.29.0", + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/typescript-estree": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0", "debug": "^4.3.4" + }, + "dependencies": { + "@typescript-eslint/scope-manager": { + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.29.0.tgz", + "integrity": "sha512-aO1PVsq7Gm+tcghabUpzEnVSFMCU4/nYIgC2GOatJcllvWfnhrgW0ZEbnTxm36QsikmCN1K/6ZgM7fok2I7xNw==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0" + } + }, + "@typescript-eslint/types": { + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.29.0.tgz", + "integrity": "sha512-wcJL/+cOXV+RE3gjCyl/V2G877+2faqvlgtso/ZRbTCnZazh0gXhe+7gbAnfubzN2bNsBtZjDvlh7ero8uIbzg==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.29.0.tgz", + "integrity": "sha512-yOfen3jE9ISZR/hHpU/bmNvTtBW1NjRbkSFdZOksL1N+ybPEE7UVGMwqvS6CP022Rp00Sb0tdiIkhSCe6NI8ow==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.29.0.tgz", + "integrity": "sha512-Sne/pVz8ryR03NFK21VpN88dZ2FdQXOlq3VIklbrTYEt8yXtRFr9tvUhqvCeKjqYk5FSim37sHbooT6vzBTZcg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.29.0", + "eslint-visitor-keys": "^4.2.0" + } + }, + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true + }, + "minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "requires": {} + } } }, "@typescript-eslint/scope-manager": { @@ -11361,9 +11607,9 @@ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==" }, "semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==" + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==" }, "shebang-command": { "version": "2.0.0", @@ -11575,9 +11821,9 @@ "requires": {} }, "ts-jest": { - "version": "29.2.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.5.tgz", - "integrity": "sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==", + "version": "29.3.1", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.3.1.tgz", + "integrity": "sha512-FT2PIRtZABwl6+ZCry8IY7JZ3xMuppsEV9qFVHOVe8jDzggwUZ9TsM4chyJxL9yi6LvkqcZYU3LmapEE454zBQ==", "dev": true, "requires": { "bs-logger": "^0.2.6", @@ -11587,7 +11833,8 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.6.3", + "semver": "^7.7.1", + "type-fest": "^4.38.0", "yargs-parser": "^21.1.1" } }, @@ -11652,6 +11899,12 @@ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true }, + "type-fest": { + "version": "4.38.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.38.0.tgz", + "integrity": "sha512-2dBz5D5ycHIoliLYLi0Q2V7KRaDlH0uWIvmk7TYlAg5slqwiPv1ezJdZm1QEM0xgk29oYWMCbIG7E6gHpvChlg==", + "dev": true + }, "typescript": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", @@ -11686,6 +11939,19 @@ "ts-api-utils": "^1.3.0" } }, + "@typescript-eslint/parser": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.19.0.tgz", + "integrity": "sha512-6M8taKyOETY1TKHp0x8ndycipTVgmp4xtg5QpEZzXxDhNvvHOJi5rLRkLr8SK3jTgD5l4fTlvBiRdfsuWydxBw==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "8.19.0", + "@typescript-eslint/types": "8.19.0", + "@typescript-eslint/typescript-estree": "8.19.0", + "@typescript-eslint/visitor-keys": "8.19.0", + "debug": "^4.3.4" + } + }, "@typescript-eslint/type-utils": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.19.0.tgz", diff --git a/superset-websocket/package.json b/superset-websocket/package.json index 152b8058b2a13e9357acb04fa84a7fec3a1e1d7d..651172e97e5cbca46047e9de8874622799b02e1b 100644 --- a/superset-websocket/package.json +++ b/superset-websocket/package.json @@ -38,14 +38,14 @@ "@types/uuid": "^10.0.0", "@types/ws": "^8.5.12", "@typescript-eslint/eslint-plugin": "^8.26.0", - "@typescript-eslint/parser": "^8.6.0", + "@typescript-eslint/parser": "^8.29.0", "eslint": "^9.17.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-lodash": "^8.0.0", "globals": "^16.0.0", "jest": "^29.7.0", "prettier": "^3.4.2", - "ts-jest": "^29.2.5", + "ts-jest": "^29.3.1", "ts-node": "^10.9.2", "tscw-config": "^1.1.2", "typescript": "^5.6.2", diff --git a/superset/async_events/cache_backend.py b/superset/async_events/cache_backend.py index 15887e47ab160c50aac5708d608da6735bb13a7d..9158e2d119ad40e949d492a6a7b45939b4580614 100644 --- a/superset/async_events/cache_backend.py +++ b/superset/async_events/cache_backend.py @@ -95,6 +95,11 @@ class RedisCacheBackend(RedisCache): "ssl_cert_reqs": config.get("CACHE_REDIS_SSL_CERT_REQS", "required"), "ssl_ca_certs": config.get("CACHE_REDIS_SSL_CA_CERTS", None), } + + # Handle username separately as it's optional for Redis authentication. + if configured_username := config.get("CACHE_REDIS_USER"): + kwargs["username"] = configured_username + return cls(**kwargs) diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py index 75989597a37bcb54d6d0acd782ee94a43bd2ffd0..7d004431b0b28784336b49ef6dca7b3d74b41f7c 100644 --- a/superset/charts/schemas.py +++ b/superset/charts/schemas.py @@ -1564,6 +1564,7 @@ class ImportV1ChartSchema(Schema): dataset_uuid = fields.UUID(required=True) is_managed_externally = fields.Boolean(allow_none=True, dump_default=False) external_url = fields.String(allow_none=True) + tags = fields.List(fields.String(), allow_none=True) class ChartCacheWarmUpRequestSchema(Schema): diff --git a/superset/commands/chart/export.py b/superset/commands/chart/export.py index a84dfcca147dbad02216377611ca8eaf8d8b512a..50b06cb30de187c4df1512babac70e48136bddf7 100644 --- a/superset/commands/chart/export.py +++ b/superset/commands/chart/export.py @@ -26,10 +26,13 @@ from superset.commands.chart.exceptions import ChartNotFoundError from superset.daos.chart import ChartDAO from superset.commands.dataset.export import ExportDatasetsCommand from superset.commands.export.models import ExportModelsCommand +from superset.commands.tag.export import ExportTagsCommand from superset.models.slice import Slice +from superset.tags.models import TagType from superset.utils.dict_import_export import EXPORT_VERSION from superset.utils.file import get_filename from superset.utils import json +from superset.extensions import feature_flag_manager logger = logging.getLogger(__name__) @@ -71,9 +74,23 @@ class ExportChartsCommand(ExportModelsCommand): if model.table: payload["dataset_uuid"] = str(model.table.uuid) + # Fetch tags from the database if TAGGING_SYSTEM is enabled + if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"): + tags = getattr(model, "tags", []) + payload["tags"] = [tag.name for tag in tags if tag.type == TagType.custom] file_content = yaml.safe_dump(payload, sort_keys=False) return file_content + _include_tags: bool = True # Default to True + + @classmethod + def disable_tag_export(cls) -> None: + cls._include_tags = False + + @classmethod + def enable_tag_export(cls) -> None: + cls._include_tags = True + @staticmethod def _export( model: Slice, export_related: bool = True @@ -85,3 +102,12 @@ class ExportChartsCommand(ExportModelsCommand): if model.table and export_related: yield from ExportDatasetsCommand([model.table.id]).run() + + # Check if the calling class is ExportDashboardCommands + if ( + export_related + and ExportChartsCommand._include_tags + and feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM") + ): + chart_id = model.id + yield from ExportTagsCommand().export(chart_ids=[chart_id]) diff --git a/superset/commands/chart/importers/v1/__init__.py b/superset/commands/chart/importers/v1/__init__.py index 8ba24035abde49f3654fa5e72a4e62d86b68dc3c..1b28e19df5ae1d8dfc6c97f71959657221e8a2d4 100644 --- a/superset/commands/chart/importers/v1/__init__.py +++ b/superset/commands/chart/importers/v1/__init__.py @@ -14,23 +14,27 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from __future__ import annotations from typing import Any from marshmallow import Schema from sqlalchemy.orm import Session # noqa: F401 +from superset import db from superset.charts.schemas import ImportV1ChartSchema from superset.commands.chart.exceptions import ChartImportError from superset.commands.chart.importers.v1.utils import import_chart from superset.commands.database.importers.v1.utils import import_database from superset.commands.dataset.importers.v1.utils import import_dataset from superset.commands.importers.v1 import ImportModelsCommand +from superset.commands.importers.v1.utils import import_tag from superset.commands.utils import update_chart_config_dataset from superset.connectors.sqla.models import SqlaTable from superset.daos.chart import ChartDAO from superset.databases.schemas import ImportV1DatabaseSchema from superset.datasets.schemas import ImportV1DatasetSchema +from superset.extensions import feature_flag_manager class ImportChartsCommand(ImportModelsCommand): @@ -47,7 +51,13 @@ class ImportChartsCommand(ImportModelsCommand): import_error = ChartImportError @staticmethod - def _import(configs: dict[str, Any], overwrite: bool = False) -> None: # noqa: C901 + # ruff: noqa: C901 + def _import( + configs: dict[str, Any], + overwrite: bool = False, + contents: dict[str, Any] | None = None, + ) -> None: + contents = {} if contents is None else contents # discover datasets associated with charts dataset_uuids: set[str] = set() for file_name, config in configs.items(): @@ -93,4 +103,12 @@ class ImportChartsCommand(ImportModelsCommand): "datasource_name": dataset.table_name, } config = update_chart_config_dataset(config, dataset_dict) - import_chart(config, overwrite=overwrite) + chart = import_chart(config, overwrite=overwrite) + + # Handle tags using import_tag function + if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"): + if "tags" in config: + target_tag_names = config["tags"] + import_tag( + target_tag_names, contents, chart.id, "chart", db.session + ) diff --git a/superset/commands/dashboard/export.py b/superset/commands/dashboard/export.py index 719aed6be5191f44aabc72133d646974af938f8e..e0701d5e9344a8f0e3c2f396c95fe08ae0051e51 100644 --- a/superset/commands/dashboard/export.py +++ b/superset/commands/dashboard/export.py @@ -25,6 +25,7 @@ from collections.abc import Iterator import yaml from superset.commands.chart.export import ExportChartsCommand +from superset.commands.tag.export import ExportTagsCommand from superset.commands.dashboard.exceptions import DashboardNotFoundError from superset.commands.dashboard.importers.v1.utils import find_chart_uuids from superset.daos.dashboard import DashboardDAO @@ -33,9 +34,11 @@ from superset.commands.dataset.export import ExportDatasetsCommand from superset.daos.dataset import DatasetDAO from superset.models.dashboard import Dashboard from superset.models.slice import Slice +from superset.tags.models import TagType from superset.utils.dict_import_export import EXPORT_VERSION from superset.utils.file import get_filename from superset.utils import json +from superset.extensions import feature_flag_manager # Import the feature flag manager logger = logging.getLogger(__name__) @@ -112,6 +115,7 @@ class ExportDashboardsCommand(ExportModelsCommand): return f"dashboards/{file_name}.yaml" @staticmethod + # ruff: noqa: C901 def _file_content(model: Dashboard) -> str: payload = model.export_to_dict( recursive=False, @@ -159,10 +163,16 @@ class ExportDashboardsCommand(ExportModelsCommand): payload["version"] = EXPORT_VERSION + # Check if the TAGGING_SYSTEM feature is enabled + if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"): + tags = model.tags if hasattr(model, "tags") else [] + payload["tags"] = [tag.name for tag in tags if tag.type == TagType.custom] + file_content = yaml.safe_dump(payload, sort_keys=False) return file_content @staticmethod + # ruff: noqa: C901 def _export( model: Dashboard, export_related: bool = True ) -> Iterator[tuple[str, Callable[[], str]]]: @@ -173,7 +183,15 @@ class ExportDashboardsCommand(ExportModelsCommand): if export_related: chart_ids = [chart.id for chart in model.slices] - yield from ExportChartsCommand(chart_ids).run() + dashboard_ids = model.id + command = ExportChartsCommand(chart_ids) + command.disable_tag_export() + yield from command.run() + command.enable_tag_export() + if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"): + yield from ExportTagsCommand.export( + dashboard_ids=dashboard_ids, chart_ids=chart_ids + ) payload = model.export_to_dict( recursive=False, diff --git a/superset/commands/dashboard/importers/v1/__init__.py b/superset/commands/dashboard/importers/v1/__init__.py index 17fb85fcfe6e4e6dfee8b32329d4df8f5ef246f1..e0e1319c138cef93d82ada2618072af859664698 100644 --- a/superset/commands/dashboard/importers/v1/__init__.py +++ b/superset/commands/dashboard/importers/v1/__init__.py @@ -15,6 +15,8 @@ # specific language governing permissions and limitations # under the License. +from __future__ import annotations + from typing import Any from marshmallow import Schema @@ -34,11 +36,13 @@ from superset.commands.dashboard.importers.v1.utils import ( from superset.commands.database.importers.v1.utils import import_database from superset.commands.dataset.importers.v1.utils import import_dataset from superset.commands.importers.v1 import ImportModelsCommand +from superset.commands.importers.v1.utils import import_tag from superset.commands.utils import update_chart_config_dataset from superset.daos.dashboard import DashboardDAO from superset.dashboards.schemas import ImportV1DashboardSchema from superset.databases.schemas import ImportV1DatabaseSchema from superset.datasets.schemas import ImportV1DatasetSchema +from superset.extensions import feature_flag_manager from superset.migrations.shared.native_filters import migrate_dashboard from superset.models.dashboard import Dashboard, dashboard_slices @@ -58,9 +62,15 @@ class ImportDashboardsCommand(ImportModelsCommand): import_error = DashboardImportError # TODO (betodealmeida): refactor to use code from other commands - # pylint: disable=too-many-branches, too-many-locals + # pylint: disable=too-many-branches, too-many-locals, too-many-statements @staticmethod - def _import(configs: dict[str, Any], overwrite: bool = False) -> None: # noqa: C901 + # ruff: noqa: C901 + def _import( + configs: dict[str, Any], + overwrite: bool = False, + contents: dict[str, Any] | None = None, + ) -> None: + contents = {} if contents is None else contents # discover charts and datasets associated with dashboards chart_uuids: set[str] = set() dataset_uuids: set[str] = set() @@ -120,6 +130,14 @@ class ImportDashboardsCommand(ImportModelsCommand): charts.append(chart) chart_ids[str(chart.uuid)] = chart.id + # Handle tags using import_tag function + if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"): + if "tags" in config: + target_tag_names = config["tags"] + import_tag( + target_tag_names, contents, chart.id, "chart", db.session + ) + # store the existing relationship between dashboards and charts existing_relationships = db.session.execute( select([dashboard_slices.c.dashboard_id, dashboard_slices.c.slice_id]) @@ -140,6 +158,18 @@ class ImportDashboardsCommand(ImportModelsCommand): if (dashboard.id, chart_id) not in existing_relationships: dashboard_chart_ids.append((dashboard.id, chart_id)) + # Handle tags using import_tag function + if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"): + if "tags" in config: + target_tag_names = config["tags"] + import_tag( + target_tag_names, + contents, + dashboard.id, + "dashboard", + db.session, + ) + # set ref in the dashboard_slices table values = [ {"dashboard_id": dashboard_id, "slice_id": chart_id} diff --git a/superset/commands/database/importers/v1/__init__.py b/superset/commands/database/importers/v1/__init__.py index c8684bc5eb428e17ccc6f3f6bcec73a8ec64f8b4..aeffd0fcb63c6c995e3d472b3aa306a8ea7b3866 100644 --- a/superset/commands/database/importers/v1/__init__.py +++ b/superset/commands/database/importers/v1/__init__.py @@ -15,6 +15,8 @@ # specific language governing permissions and limitations # under the License. +from __future__ import annotations + from typing import Any from marshmallow import Schema @@ -42,7 +44,11 @@ class ImportDatabasesCommand(ImportModelsCommand): import_error = DatabaseImportError @staticmethod - def _import(configs: dict[str, Any], overwrite: bool = False) -> None: + def _import( + configs: dict[str, Any], + overwrite: bool = False, + contents: dict[str, Any] | None = None, + ) -> None: # first import databases database_ids: dict[str, int] = {} for file_name, config in configs.items(): diff --git a/superset/commands/database/update.py b/superset/commands/database/update.py index a1accf4df10414916840fb993fc59c7f2c6605ac..f562b5d7802cbb93cdac4e69127a58f681d4f3b2 100644 --- a/superset/commands/database/update.py +++ b/superset/commands/database/update.py @@ -23,7 +23,7 @@ from typing import Any from flask_appbuilder.models.sqla import Model -from superset import is_feature_enabled +from superset import db, is_feature_enabled from superset.commands.base import BaseCommand from superset.commands.database.exceptions import ( DatabaseExistsValidationError, @@ -79,13 +79,26 @@ class UpdateDatabaseCommand(BaseCommand): # existing personal tokens. self._handle_oauth2() - # if the database name changed we need to update any existing permissions, - # since they're name based + # build new DB original_database_name = self._model.database_name - + original_catalog = self._model.get_default_catalog() database = DatabaseDAO.update(self._model, self._properties) database.set_sqlalchemy_uri(database.sqlalchemy_uri) ssh_tunnel = self._handle_ssh_tunnel(database) + new_catalog = database.get_default_catalog() + + # update assets when the database catalog changes, if the database was not + # configured with multi-catalog support; if it was enabled or is enabled in the + # update we don't update the assets + if ( + new_catalog != original_catalog + and not self._model.allow_multi_catalog + and not database.allow_multi_catalog + ): + self._update_catalog_attribute(self._model.id, new_catalog) + + # if the database name changed we need to update any existing permissions, + # since they're name based try: current_username = get_username() SyncPermissionsCommand( @@ -159,6 +172,29 @@ class UpdateDatabaseCommand(BaseCommand): ssh_tunnel_properties, ).run() + def _update_catalog_attribute( + self, + database_id: int, + new_catalog: str | None, + ) -> None: + """ + Update the catalog of the datasets that are associated with database. + """ + from superset.connectors.sqla.models import SqlaTable + from superset.models.sql_lab import Query, SavedQuery, TableSchema, TabState + + for model in [ + SqlaTable, + Query, + SavedQuery, + TabState, + TableSchema, + ]: + fk = "db_id" if model == SavedQuery else "database_id" + predicate = {fk: database_id} + update = {"catalog": new_catalog} + db.session.query(model).filter_by(**predicate).update(update) + def validate(self) -> None: if database_name := self._properties.get("database_name"): if not DatabaseDAO.validate_update_uniqueness( diff --git a/superset/commands/dataset/importers/v1/__init__.py b/superset/commands/dataset/importers/v1/__init__.py index c7ecba122725da5f3766c7eb8c816a070dea7ba8..5cc562e8a40799f58fc75b11a0490981949e67d0 100644 --- a/superset/commands/dataset/importers/v1/__init__.py +++ b/superset/commands/dataset/importers/v1/__init__.py @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. -from typing import Any +from typing import Any, Optional from marshmallow import Schema from sqlalchemy.orm import Session # noqa: F401 @@ -42,7 +42,13 @@ class ImportDatasetsCommand(ImportModelsCommand): import_error = DatasetImportError @staticmethod - def _import(configs: dict[str, Any], overwrite: bool = False) -> None: + def _import( + configs: dict[str, Any], + overwrite: bool = False, + contents: Optional[dict[str, Any]] = None, + ) -> None: + if contents is None: + contents = {} # discover databases associated with datasets database_uuids: set[str] = set() for file_name, config in configs.items(): diff --git a/superset/commands/export/assets.py b/superset/commands/export/assets.py index ff76dab03dae57b47bd0f46e8ba1e48ece8787d0..2acd19d89204b08a6df2949a316fccc9c53add2c 100644 --- a/superset/commands/export/assets.py +++ b/superset/commands/export/assets.py @@ -53,6 +53,7 @@ class ExportAssetsCommand(BaseCommand): ExportDashboardsCommand, ExportSavedQueriesCommand, ] + for command in commands: ids = [model.id for model in command.dao.find_all()] for file_name, file_content in command(ids, export_related=False).run(): diff --git a/superset/commands/importers/v1/__init__.py b/superset/commands/importers/v1/__init__.py index be3bb92d0e7ed3dcdbcb44baaa1469a4ef60c10f..4f1e5d68a345f05fe57c5a76172b3894d6a5b088 100644 --- a/superset/commands/importers/v1/__init__.py +++ b/superset/commands/importers/v1/__init__.py @@ -14,8 +14,11 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. + +from __future__ import annotations + import logging -from typing import Any, Optional +from typing import Any from marshmallow import Schema, validate # noqa: F401 from marshmallow.exceptions import ValidationError @@ -64,7 +67,12 @@ class ImportModelsCommand(BaseCommand): self._configs: dict[str, Any] = {} @staticmethod - def _import(configs: dict[str, Any], overwrite: bool = False) -> None: + # ruff: noqa: C901 + def _import( + configs: dict[str, Any], + overwrite: bool = False, + contents: dict[str, Any] | None = None, + ) -> None: raise NotImplementedError("Subclasses MUST implement _import") @classmethod @@ -76,7 +84,7 @@ class ImportModelsCommand(BaseCommand): self.validate() try: - self._import(self._configs, self.overwrite) + self._import(self._configs, self.overwrite, self.contents) except CommandException: raise except Exception as ex: @@ -87,7 +95,7 @@ class ImportModelsCommand(BaseCommand): # verify that the metadata file is present and valid try: - metadata: Optional[dict[str, str]] = load_metadata(self.contents) + metadata: dict[str, str] | None = load_metadata(self.contents) except ValidationError as exc: exceptions.append(exc) metadata = None diff --git a/superset/commands/importers/v1/examples.py b/superset/commands/importers/v1/examples.py index 1ffb40c8b813fc45f90b25b05250fbb47abe59ba..ab548d35409d0fb6b4508ad0502c9168969245c6 100644 --- a/superset/commands/importers/v1/examples.py +++ b/superset/commands/importers/v1/examples.py @@ -14,7 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -from typing import Any +from typing import Any, Optional from marshmallow import Schema from sqlalchemy.exc import MultipleResultsFound @@ -90,6 +90,7 @@ class ImportExamplesCommand(ImportModelsCommand): def _import( # pylint: disable=too-many-locals, too-many-branches # noqa: C901 configs: dict[str, Any], overwrite: bool = False, + contents: Optional[dict[str, Any]] = None, force_data: bool = False, ) -> None: # import databases diff --git a/superset/commands/importers/v1/utils.py b/superset/commands/importers/v1/utils.py index 7d4e5909133cdbdca1e0ff9b21483620730e58fb..f9d3b3c2ba4b302ee7fb613d53c787d960e8c91a 100644 --- a/superset/commands/importers/v1/utils.py +++ b/superset/commands/importers/v1/utils.py @@ -21,12 +21,17 @@ from zipfile import ZipFile import yaml from marshmallow import fields, Schema, validate from marshmallow.exceptions import ValidationError +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session from superset import db from superset.commands.importers.exceptions import IncorrectVersionError from superset.databases.ssh_tunnel.models import SSHTunnel +from superset.extensions import feature_flag_manager from superset.models.core import Database +from superset.tags.models import Tag, TaggedObject from superset.utils.core import check_is_safe_zip +from superset.utils.decorators import transaction METADATA_FILE_NAME = "metadata.yaml" IMPORT_VERSION = "1.0.0" @@ -96,7 +101,8 @@ def validate_metadata_type( # pylint: disable=too-many-locals,too-many-arguments -def load_configs( # noqa: C901 +# ruff: noqa: C901 +def load_configs( contents: dict[str, str], schemas: dict[str, Schema], passwords: dict[str, str], @@ -216,6 +222,91 @@ def get_contents_from_bundle(bundle: ZipFile) -> dict[str, str]: } +# pylint: disable=consider-using-transaction +# ruff: noqa: C901 +@transaction() +def import_tag( + target_tag_names: list[str], + contents: dict[str, Any], + object_id: int, + object_type: str, + db_session: Session, +) -> list[int]: + """Handles the import logic for tags for charts and dashboards""" + + if not feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"): + return [] + + tag_descriptions = {} + new_tag_ids = [] + + if "tags.yaml" in contents: + try: + tags_config = yaml.safe_load(contents["tags.yaml"]) + except yaml.YAMLError as err: + logger.error("Error parsing tags.yaml: %s", err) + tags_config = {} + + for tag_info in tags_config.get("tags", []): + tag_name = tag_info.get("tag_name") + description = tag_info.get("description", None) + if tag_name: + tag_descriptions[tag_name] = description + + existing_assocs = ( + db_session.query(TaggedObject) + .filter_by(object_id=object_id, object_type=object_type) + .all() + ) + + existing_tags = { + tag.name: tag + for tag in db_session.query(Tag).filter(Tag.name.in_(target_tag_names)) + } + + for tag_name in target_tag_names: + try: + tag = existing_tags.get(tag_name) + + # If tag does not exist, create it + if tag is None: + description = tag_descriptions.get(tag_name, None) + tag = Tag(name=tag_name, description=description, type="custom") + db_session.add(tag) + existing_tags[tag_name] = tag # Update the existing_tags dictionary + + # Ensure the association with the object + tagged_object = ( + db_session.query(TaggedObject) + .filter_by(object_id=object_id, object_type=object_type, tag_id=tag.id) + .first() + ) + if not tagged_object: + new_tagged_object = TaggedObject( + tag_id=tag.id, object_id=object_id, object_type=object_type + ) + db_session.add(new_tagged_object) + + new_tag_ids.append(tag.id) + + except SQLAlchemyError as err: + logger.error( + "Error processing tag '%s' for %s ID %d: %s", + tag_name, + object_type, + object_id, + err, + ) + continue # No need for manual rollback, handled by transaction decorator + + # Remove old tags not in the new config + for tag in existing_assocs: + if tag.tag_id not in new_tag_ids: + db_session.delete(tag) + + return new_tag_ids + + def get_resource_mappings_batched( model_class: Type[Any], batch_size: int = 1000, diff --git a/superset/commands/query/importers/v1/__init__.py b/superset/commands/query/importers/v1/__init__.py index 3dc25d93a194d069d95d6a977fc780b695b30849..1f7290d6f7c6bc408ee22331bf5aee9a515f6f78 100644 --- a/superset/commands/query/importers/v1/__init__.py +++ b/superset/commands/query/importers/v1/__init__.py @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. -from typing import Any +from typing import Any, Optional from marshmallow import Schema from sqlalchemy.orm import Session # noqa: F401 @@ -43,7 +43,11 @@ class ImportSavedQueriesCommand(ImportModelsCommand): import_error = SavedQueryImportError @staticmethod - def _import(configs: dict[str, Any], overwrite: bool = False) -> None: + def _import( + configs: dict[str, Any], + overwrite: bool = False, + contents: Optional[dict[str, Any]] = None, + ) -> None: # discover databases associated with saved queries database_uuids: set[str] = set() for file_name, config in configs.items(): diff --git a/superset/commands/tag/export.py b/superset/commands/tag/export.py new file mode 100644 index 0000000000000000000000000000000000000000..3276e2865bae774d445b52e3217380ed86bca732 --- /dev/null +++ b/superset/commands/tag/export.py @@ -0,0 +1,131 @@ +# 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. +# isort:skip_file + + +from typing import Any, Callable, List, Optional, Union +from collections.abc import Iterator + +import yaml +from superset.daos.chart import ChartDAO +from superset.daos.dashboard import DashboardDAO +from superset.extensions import feature_flag_manager +from superset.tags.models import TagType +from superset.commands.tag.exceptions import TagNotFoundError + + +# pylint: disable=too-few-public-methods +class ExportTagsCommand: + not_found = TagNotFoundError + + @staticmethod + def _file_name() -> str: + # Use the model to determine the filename + return "tags.yaml" + + @staticmethod + def _merge_tags( + dashboard_tags: List[dict[str, Any]], chart_tags: List[dict[str, Any]] + ) -> List[dict[str, Any]]: + # Create a dictionary to prevent duplicates based on tag name + tags_dict = {tag["tag_name"]: tag for tag in dashboard_tags} + + # Add chart tags, preserving unique tag names + for tag in chart_tags: + if tag["tag_name"] not in tags_dict: + tags_dict[tag["tag_name"]] = tag + + # Return merged tags as a list + return list(tags_dict.values()) + + @staticmethod + def _file_content( + dashboard_ids: Optional[Union[int, List[Union[int, str]]]] = None, + chart_ids: Optional[Union[int, List[Union[int, str]]]] = None, + ) -> str: + payload: dict[str, list[dict[str, Any]]] = {"tags": []} + + dashboard_tags = [] + chart_tags = [] + + # Fetch dashboard tags if provided + if dashboard_ids: + # Ensure dashboard_ids is a list + if isinstance(dashboard_ids, int): + dashboard_ids = [ + dashboard_ids + ] # Convert single int to list for consistency + + dashboards = [ + dashboard + for dashboard in ( + DashboardDAO.find_by_id(dashboard_id) + for dashboard_id in dashboard_ids + ) + if dashboard is not None + ] + + for dashboard in dashboards: + tags = dashboard.tags if hasattr(dashboard, "tags") else [] + filtered_tags = [ + {"tag_name": tag.name, "description": tag.description} + for tag in tags + if tag.type == TagType.custom + ] + dashboard_tags.extend(filtered_tags) + + # Fetch chart tags if provided + if chart_ids: + # Ensure chart_ids is a list + if isinstance(chart_ids, int): + chart_ids = [chart_ids] # Convert single int to list for consistency + + charts = [ + chart + for chart in (ChartDAO.find_by_id(chart_id) for chart_id in chart_ids) + if chart is not None + ] + + for chart in charts: + tags = chart.tags if hasattr(chart, "tags") else [] + filtered_tags = [ + {"tag_name": tag.name, "description": tag.description} + for tag in tags + if "type:" not in tag.name and "owner:" not in tag.name + ] + chart_tags.extend(filtered_tags) + + # Merge the tags from both dashboards and charts + merged_tags = ExportTagsCommand._merge_tags(dashboard_tags, chart_tags) + payload["tags"].extend(merged_tags) + + # Convert to YAML format + file_content = yaml.safe_dump(payload, sort_keys=False) + return file_content + + @staticmethod + def export( + dashboard_ids: Optional[Union[int, List[Union[int, str]]]] = None, + chart_ids: Optional[Union[int, List[Union[int, str]]]] = None, + ) -> Iterator[tuple[str, Callable[[], str]]]: + if not feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"): + return + + yield ( + ExportTagsCommand._file_name(), + lambda: ExportTagsCommand._file_content(dashboard_ids, chart_ids), + ) diff --git a/superset/config.py b/superset/config.py index 63ec951c0ab5d10c43d97c67978888772f2359cb..ced2f56c897a80363b00b8ef0fe6dc152cd95d7d 100644 --- a/superset/config.py +++ b/superset/config.py @@ -822,7 +822,7 @@ STORE_CACHE_KEYS_IN_METADATA_DB = False # CORS Options # NOTE: enabling this requires installing the cors-related python dependencies -# `pip install .[cors]` or `pip install apache-superset[cors]`, depending +# `pip install .[cors]` or `pip install apache_superset[cors]`, depending ENABLE_CORS = False CORS_OPTIONS: dict[Any, Any] = {} @@ -1021,6 +1021,7 @@ class CeleryConfig: # pylint: disable=too-few-public-methods "superset.tasks.scheduler", "superset.tasks.thumbnails", "superset.tasks.cache", + "superset.tasks.slack", ) result_backend = "db+sqlite:///celery_results.sqlite" worker_prefetch_multiplier = 1 @@ -1052,6 +1053,11 @@ class CeleryConfig: # pylint: disable=too-few-public-methods # "schedule": crontab(minute="*", hour="*"), # "kwargs": {"retention_period_days": 180}, # }, + # Uncomment to enable Slack channel cache warm-up + # "slack.cache_channels": { + # "task": "slack.cache_channels", + # "schedule": crontab(minute="0", hour="*"), + # }, } @@ -1251,6 +1257,7 @@ ENABLE_CHUNK_ENCODING = False SILENCE_FAB = True FAB_ADD_SECURITY_VIEWS = True +FAB_ADD_SECURITY_API = True FAB_ADD_SECURITY_PERMISSION_VIEW = False FAB_ADD_SECURITY_VIEW_MENU_VIEW = False FAB_ADD_SECURITY_PERMISSION_VIEWS_VIEW = False @@ -1494,6 +1501,7 @@ EMAIL_REPORTS_CTA = "Explore in Superset" # Slack API token for the superset reports, either string or callable SLACK_API_TOKEN: Callable[[], str] | str | None = None SLACK_PROXY = None +SLACK_CACHE_TIMEOUT = int(timedelta(days=1).total_seconds()) # The webdriver to use for generating reports. Use one of the following # firefox @@ -1618,6 +1626,9 @@ CONTENT_SECURITY_POLICY_WARNING = True TALISMAN_ENABLED = utils.cast_to_boolean(os.environ.get("TALISMAN_ENABLED", True)) # If you want Talisman, how do you want it configured?? +# For more information on setting up Talisman, please refer to +# https://superset.apache.org/docs/configuration/networking-settings/#changing-flask-talisman-csp + TALISMAN_CONFIG = { "content_security_policy": { "base-uri": ["'self'"], @@ -1628,7 +1639,7 @@ TALISMAN_CONFIG = { "data:", "https://apachesuperset.gateway.scarf.sh", "https://static.scarf.sh/", - # "https://avatars.slack-edge.com", # Uncomment when SLACK_ENABLE_AVATARS is True # noqa: E501 + # "https://cdn.brandfolder.io", # Uncomment when SLACK_ENABLE_AVATARS is True # noqa: E501 "ows.terrestris.de", ], "worker-src": ["'self'", "blob:"], @@ -1659,7 +1670,7 @@ TALISMAN_DEV_CONFIG = { "data:", "https://apachesuperset.gateway.scarf.sh", "https://static.scarf.sh/", - "https://avatars.slack-edge.com", + "https://cdn.brandfolder.io", "ows.terrestris.de", ], "worker-src": ["'self'", "blob:"], diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index c7becfe8a16aea4f3b9e76950479e14d1d87389d..b578ee081d018b51740105447937dd5f108bf538 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -1087,16 +1087,19 @@ class DashboardRestApi(BaseSupersetModelRestApi): "urlParams": payload.get("urlParams", []), } - permalink_key = CreateDashboardPermalinkCommand( - dashboard_id=str(dashboard.id), - state=dashboard_state, - ).run() + # if the permalink key is provided, dashboard_state will be ignored + # else, create a permalink key from the dashboard_state + permalink_key = ( + payload.get("permalinkKey", None) + or CreateDashboardPermalinkCommand( + dashboard_id=str(dashboard.id), + state=dashboard_state, + ).run() + ) dashboard_url = get_url_path("Superset.dashboard_permalink", key=permalink_key) screenshot_obj = DashboardScreenshot(dashboard_url, dashboard.digest) - cache_key = screenshot_obj.get_cache_key( - window_size, thumb_size, dashboard_state - ) + cache_key = screenshot_obj.get_cache_key(window_size, thumb_size, permalink_key) image_url = get_url_path( "DashboardRestApi.screenshot", pk=dashboard.id, digest=cache_key ) diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py index ded83246691f4a11b8ab10c4e6e0f5935b2fd40e..db581f42dffac668a82ca0c2e706166e4cd8898d 100644 --- a/superset/dashboards/schemas.py +++ b/superset/dashboards/schemas.py @@ -484,6 +484,7 @@ class ImportV1DashboardSchema(Schema): certified_by = fields.String(allow_none=True) certification_details = fields.String(allow_none=True) published = fields.Boolean(allow_none=True) + tags = fields.List(fields.String(), allow_none=True) class EmbeddedDashboardConfigSchema(Schema): @@ -521,3 +522,4 @@ class CacheScreenshotSchema(Schema): urlParams = fields.List( # noqa: N815 fields.List(fields.Str(), validate=lambda x: len(x) == 2), required=False ) + permalinkKey = fields.Str(required=False) # noqa: N815 diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py index 79e0eb3bfd73bff8c8bbd4ecaeb99af8d3e2774f..eaecb74020f4e9ad4719ff90833ab28df2d3cc98 100644 --- a/superset/db_engine_specs/base.py +++ b/superset/db_engine_specs/base.py @@ -1630,6 +1630,7 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods :return: SqlAlchemy query with additional where clause referencing the latest partition """ + # TODO: Fix circular import caused by importing Database, TableColumn return None @classmethod diff --git a/superset/db_engine_specs/bigquery.py b/superset/db_engine_specs/bigquery.py index c6c57b1624a07ccdfee80b415201051d35fadffc..cf5cfaad511b66e7ac6b2cdd8e33a6100ae5fda4 100644 --- a/superset/db_engine_specs/bigquery.py +++ b/superset/db_engine_specs/bigquery.py @@ -27,15 +27,15 @@ from typing import Any, TYPE_CHECKING, TypedDict import pandas as pd from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin -from deprecation import deprecated from flask_babel import gettext as __ from marshmallow import fields, Schema from marshmallow.exceptions import ValidationError -from sqlalchemy import column, types +from sqlalchemy import column, func, types from sqlalchemy.engine.base import Engine from sqlalchemy.engine.reflection import Inspector from sqlalchemy.engine.url import URL -from sqlalchemy.sql import sqltypes +from sqlalchemy.sql import column as sql_column, select, sqltypes +from sqlalchemy.sql.expression import table as sql_table from superset.constants import TimeGrain from superset.databases.schemas import encrypted_field_properties, EncryptedString @@ -50,6 +50,11 @@ from superset.superset_typing import ResultSetColumnType from superset.utils import core as utils, json from superset.utils.hashing import md5_sha_from_str +if TYPE_CHECKING: + from sqlalchemy.sql.expression import Select + +logger = logging.getLogger(__name__) + try: import google.auth from google.cloud import bigquery @@ -289,42 +294,80 @@ class BigQueryEngineSpec(BaseEngineSpec): # pylint: disable=too-many-public-met return "_" + md5_sha_from_str(label) @classmethod - @deprecated(deprecated_in="3.0") - def normalize_indexes(cls, indexes: list[dict[str, Any]]) -> list[dict[str, Any]]: - """ - Normalizes indexes for more consistency across db engines + def where_latest_partition( + cls, + database: Database, + table: Table, + query: Select, + columns: list[ResultSetColumnType] | None = None, + ) -> Select | None: + if partition_column := cls.get_time_partition_column(database, table): + max_partition_id = cls.get_max_partition_id(database, table) + query = query.where( + column(partition_column) == func.PARSE_DATE("%Y%m%d", max_partition_id) + ) - :param indexes: Raw indexes as returned by SQLAlchemy - :return: cleaner, more aligned index definition - """ - normalized_idxs = [] - # Fixing a bug/behavior observed in pybigquery==0.4.15 where - # the index's `column_names` == [None] - # Here we're returning only non-None indexes - for ix in indexes: - column_names = ix.get("column_names") or [] - ix["column_names"] = [col for col in column_names if col is not None] - if ix["column_names"]: - normalized_idxs.append(ix) - return normalized_idxs + return query @classmethod - def get_indexes( + def get_max_partition_id( cls, database: Database, - inspector: Inspector, table: Table, - ) -> list[dict[str, Any]]: - """ - Get the indexes associated with the specified schema/table. + ) -> Select | None: + # Compose schema from catalog and schema + schema_parts = [] + if table.catalog: + schema_parts.append(table.catalog) + if table.schema: + schema_parts.append(table.schema) + schema_parts.append("INFORMATION_SCHEMA") + schema = ".".join(schema_parts) + # Define a virtual table reference to INFORMATION_SCHEMA.PARTITIONS + partitions_table = sql_table( + "PARTITIONS", + sql_column("partition_id"), + sql_column("table_name"), + schema=schema, + ) - :param database: The database to inspect - :param inspector: The SQLAlchemy inspector - :param table: The table instance to inspect - :returns: The indexes - """ + # Build the query + query = select( + func.max(partitions_table.c.partition_id).label("max_partition_id") + ).where(partitions_table.c.table_name == table.table) - return cls.normalize_indexes(inspector.get_indexes(table.table, table.schema)) + # Compile to BigQuery SQL + compiled_query = query.compile( + dialect=database.get_dialect(), + compile_kwargs={"literal_binds": True}, + ) + + # Run the query and handle result + with database.get_raw_connection( + catalog=table.catalog, + schema=table.schema, + ) as conn: + cursor = conn.cursor() + cursor.execute(str(compiled_query)) + if row := cursor.fetchone(): + return row[0] + return None + + @classmethod + def get_time_partition_column( + cls, + database: Database, + table: Table, + ) -> str | None: + with cls.get_engine( + database, catalog=table.catalog, schema=table.schema + ) as engine: + client = cls._get_client(engine, database) + bq_table = client.get_table(f"{table.schema}.{table.table}") + + if bq_table.time_partitioning: + return bq_table.time_partitioning.field + return None @classmethod def get_extra_table_metadata( @@ -332,23 +375,38 @@ class BigQueryEngineSpec(BaseEngineSpec): # pylint: disable=too-many-public-met database: Database, table: Table, ) -> dict[str, Any]: - indexes = database.get_indexes(table) - if not indexes: - return {} - partitions_columns = [ - index.get("column_names", []) - for index in indexes - if index.get("name") == "partition" - ] - cluster_columns = [ - index.get("column_names", []) - for index in indexes - if index.get("name") == "clustering" - ] - return { - "partitions": {"cols": partitions_columns}, - "clustering": {"cols": cluster_columns}, - } + payload = {} + partition_column = cls.get_time_partition_column(database, table) + with cls.get_engine( + database, catalog=table.catalog, schema=table.schema + ) as engine: + if partition_column: + max_partition_id = cls.get_max_partition_id(database, table) + sql = cls.select_star( + database, + table, + engine, + indent=False, + show_cols=False, + latest_partition=True, + ) + payload.update( + { + "partitions": { + "cols": [partition_column], + "latest": {partition_column: max_partition_id}, + "partitionQuery": sql, + }, + "indexes": [ + { + "name": "partitioned", + "cols": [partition_column], + "type": "partitioned", + } + ], + } + ) + return payload @classmethod def epoch_to_dttm(cls) -> str: diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index 7574989abf80d2b72352033c9661a4c223b9595b..fe7f9084009aa163dfa545187a1c12cba4d5e96e 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -353,7 +353,7 @@ class DatabricksDynamicBaseEngineSpec(BasicParametersMixin, DatabricksBaseEngine class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec): engine = "databricks" - engine_name = "Databricks" + engine_name = "Databricks (legacy)" drivers = {"connector": "Native all-purpose driver"} default_driver = "connector" @@ -485,7 +485,7 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec): class DatabricksPythonConnectorEngineSpec(DatabricksDynamicBaseEngineSpec): engine = "databricks" - engine_name = "Databricks Python Connector" + engine_name = "Databricks" default_driver = "databricks-sql-python" drivers = {"databricks-sql-python": "Databricks SQL Python"} diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 38c3516dbcef40097c496786900a2576f606a3b9..e744dff4720794dcd4e36323647d2d73e2f415d2 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -154,7 +154,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods from superset.reports.api import ReportScheduleRestApi from superset.reports.logs.api import ReportExecutionLogRestApi from superset.row_level_security.api import RLSRestApi - from superset.security.api import SecurityRestApi + from superset.security.api import RoleRestAPI, SecurityRestApi from superset.sqllab.api import SqlLabRestApi from superset.sqllab.permalink.api import SqlLabPermalinkRestApi from superset.tags.api import TagRestApi @@ -176,6 +176,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods from superset.views.explore import ExplorePermalinkView, ExploreView from superset.views.log.api import LogRestApi from superset.views.log.views import LogModelView + from superset.views.roles import RolesListView from superset.views.sql_lab.views import ( SavedQueryView, TableSchemaView, @@ -306,6 +307,15 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods ################### + appbuilder.add_view( + RolesListView, + "List Roles", + label=__("List Roles"), + category="Security", + category_label=__("Security"), + icon="fa-lock", + ) + appbuilder.add_view( DynamicPluginsView, "Plugins", @@ -346,6 +356,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods appbuilder.add_view_no_menu(TaggedObjectsModelView) appbuilder.add_view_no_menu(TagView) appbuilder.add_view_no_menu(ReportView) + appbuilder.add_view_no_menu(RoleRestAPI) # # Add links diff --git a/superset/jinja_context.py b/superset/jinja_context.py index a4d8e6d6a491928d78028a93a82dc5efead5eb30..c4182b136eb3e5c29e3bc2787b1b03357d2a1d35 100644 --- a/superset/jinja_context.py +++ b/superset/jinja_context.py @@ -33,6 +33,7 @@ from sqlalchemy.engine.interfaces import Dialect from sqlalchemy.sql.expression import bindparam from sqlalchemy.types import String +from superset import security_manager from superset.commands.dataset.exceptions import DatasetNotFoundError from superset.common.utils.time_range_utils import get_since_until_from_time_range from superset.constants import LRU_CACHE_MAX_SIZE, NO_TIME_RANGE @@ -108,6 +109,7 @@ class ExtraCache: r"current_user_id\([^()]*\)|" r"current_username\([^()]*\)|" r"current_user_email\([^()]*\)|" + r"current_user_roles\([^()]*\)|" r"cache_key_wrapper\([^()]*\)|" r"url_param\([^()]*\)" r")" @@ -172,6 +174,25 @@ class ExtraCache: return email_address return None + def current_user_roles(self, add_to_cache_keys: bool = True) -> list[str] | None: + """ + Return the sorted list of roles of the user who is currently logged in. + + :param add_to_cache_keys: Whether the value should be included in the cache key + :returns: List of role names + """ + try: + user_roles = sorted( + [role.name for role in security_manager.get_user_roles()] + ) + if not user_roles: + return None + if add_to_cache_keys: + self.cache_key_wrapper(json.dumps(user_roles)) + return user_roles + except Exception: # pylint: disable=broad-except + return None + def cache_key_wrapper(self, key: Any) -> Any: """ Adds values to a list that is added to the query object used for calculating a @@ -561,6 +582,24 @@ class WhereInMacro: # pylint: disable=too-few-public-methods return result +def to_datetime( + value: str | None, format: str = "%Y-%m-%d %H:%M:%S" +) -> datetime | None: + """ + Parses a string into a datetime object. + + :param value: the string to parse. + :param format: the format to parse the string with. + :returns: the parsed datetime object. + """ + if not value: + return None + + # This value might come from a macro that could be including wrapping quotes + value = value.strip("'\"") + return datetime.strptime(value, format) + + class BaseTemplateProcessor: """ Base class for database-specific jinja context @@ -596,6 +635,7 @@ class BaseTemplateProcessor: # custom filters self.env.filters["where_in"] = WhereInMacro(database.get_dialect()) + self.env.filters["to_datetime"] = to_datetime def set_context(self, **kwargs: Any) -> None: self._context.update(kwargs) @@ -670,6 +710,9 @@ class JinjaTemplateProcessor(BaseTemplateProcessor): "current_user_email": partial( safe_proxy, extra_cache.current_user_email ), + "current_user_roles": partial( + safe_proxy, extra_cache.current_user_roles + ), "cache_key_wrapper": partial(safe_proxy, extra_cache.cache_key_wrapper), "filter_values": partial(safe_proxy, extra_cache.filter_values), "get_filters": partial(safe_proxy, extra_cache.get_filters), diff --git a/superset/migrations/shared/migrate_viz/processors.py b/superset/migrations/shared/migrate_viz/processors.py index 99c545d98fa983d8fa7536192e34eb58dbb23f59..44e5aacfb0245cfd3aeaf7e96d7d4f9b09563110 100644 --- a/superset/migrations/shared/migrate_viz/processors.py +++ b/superset/migrations/shared/migrate_viz/processors.py @@ -236,6 +236,7 @@ class MigrateDistBarChart(TimeseriesChart): self.remove_keys.add("bar_stacked") self.data["stack"] = "Stack" if self.data.get("bar_stacked") else None + self.data["x_ticks_layout"] = 45 class MigrateBubbleChart(MigrateViz): diff --git a/superset/migrations/versions/2024-09-25_17-59_7b17aa722e30_uuidmixin.py b/superset/migrations/versions/2024-09-25_17-59_7b17aa722e30_uuidmixin.py index 2366d3bf26135ccdfa975f25f42355669799ad77..9cc3735f28c30d4475c031568574ada9ed8bc68a 100644 --- a/superset/migrations/versions/2024-09-25_17-59_7b17aa722e30_uuidmixin.py +++ b/superset/migrations/versions/2024-09-25_17-59_7b17aa722e30_uuidmixin.py @@ -24,7 +24,8 @@ Create Date: 2024-09-25 17:59:21.028426 import sqlalchemy as sa import sqlalchemy_utils -from alembic import op + +from superset.migrations.shared.utils import add_columns, drop_columns # revision identifiers, used by Alembic. revision = "7b17aa722e30" @@ -32,16 +33,16 @@ down_revision = "48cbb571fa3a" def upgrade(): - op.add_column( + add_columns( "css_templates", sa.Column("uuid", sqlalchemy_utils.types.uuid.UUIDType(), nullable=True), ) - op.add_column( + add_columns( "favstar", sa.Column("uuid", sqlalchemy_utils.types.uuid.UUIDType(), nullable=True), ) def downgrade(): - op.drop_column("css_templates", "uuid") - op.drop_column("favstar", "uuid") + drop_columns("css_templates", "uuid") + drop_columns("favstar", "uuid") diff --git a/superset/migrations/versions/2025-03-03_20-52_94e7a3499973_add_folders_column_to_dataset.py b/superset/migrations/versions/2025-03-03_20-52_94e7a3499973_add_folders_column_to_dataset.py index 3e767f36a6fc4c9c88ad1941ec678dab12d21f38..e9f7d387c512cdc29509c583f0bfb30794465d70 100644 --- a/superset/migrations/versions/2025-03-03_20-52_94e7a3499973_add_folders_column_to_dataset.py +++ b/superset/migrations/versions/2025-03-03_20-52_94e7a3499973_add_folders_column_to_dataset.py @@ -23,20 +23,21 @@ Create Date: 2025-03-03 20:52:24.585143 """ import sqlalchemy as sa -from alembic import op from sqlalchemy.types import JSON +from superset.migrations.shared.utils import add_columns, drop_columns + # revision identifiers, used by Alembic. revision = "94e7a3499973" down_revision = "74ad1125881c" def upgrade(): - op.add_column( + add_columns( "tables", sa.Column("folders", JSON, nullable=True), ) def downgrade(): - op.drop_column("tables", "folders") + drop_columns("tables", "folders") diff --git a/superset/models/helpers.py b/superset/models/helpers.py index 33c7c17d170e9a1acd0c7fe7b021e8173a0abd0d..5dc97b602af248071b8aacb4998e3a427e3d55fa 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -1380,7 +1380,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods if engine.dialect.identifier_preparer._double_percents: sql = sql.replace("%%", "%") - df = pd.read_sql_query(sql=sql, con=engine) + df = pd.read_sql_query(sql=self.text(sql), con=engine) # replace NaN with None to ensure it can be serialized to JSON df = df.replace({np.nan: None}) return df["column_values"].to_list() diff --git a/superset/queries/saved_queries/schemas.py b/superset/queries/saved_queries/schemas.py index 7c314e63911d0dec1ee29e1e1f22f440ea5b087f..152ddcd0870b4424d93ea756716bc3d901785486 100644 --- a/superset/queries/saved_queries/schemas.py +++ b/superset/queries/saved_queries/schemas.py @@ -39,6 +39,7 @@ get_export_ids_schema = {"type": "array", "items": {"type": "integer"}} class ImportV1SavedQuerySchema(Schema): + catalog = fields.String(allow_none=True, validate=Length(0, 128)) schema = fields.String(allow_none=True, validate=Length(0, 128)) label = fields.String(allow_none=True, validate=Length(0, 256)) description = fields.String(allow_none=True) diff --git a/superset/security/api.py b/superset/security/api.py index 02bf6b7101ea8e1b72429c454155c02ca33cbc3a..756f1d7bbd3e1500cdd03c8a18e38ee7c31b58e1 100644 --- a/superset/security/api.py +++ b/superset/security/api.py @@ -19,16 +19,21 @@ from typing import Any from flask import current_app, request, Response from flask_appbuilder import expose -from flask_appbuilder.api import safe +from flask_appbuilder.api import rison, safe +from flask_appbuilder.api.schemas import get_list_schema from flask_appbuilder.security.decorators import permission_name, protect +from flask_appbuilder.security.sqla.models import Role from flask_wtf.csrf import generate_csrf from marshmallow import EXCLUDE, fields, post_load, Schema, ValidationError +from sqlalchemy import asc, desc +from sqlalchemy.orm import joinedload from superset.commands.dashboard.embedded.exceptions import ( EmbeddedDashboardNotFoundError, ) +from superset.commands.exceptions import ForbiddenError from superset.exceptions import SupersetGenericErrorException -from superset.extensions import event_logger +from superset.extensions import db, event_logger from superset.security.guest_token import GuestTokenResourceType from superset.views.base_api import BaseSupersetApi, statsd_metrics @@ -76,6 +81,19 @@ class GuestTokenCreateSchema(PermissiveSchema): rls = fields.List(fields.Nested(RlsRuleSchema), required=True) +class RoleResponseSchema(PermissiveSchema): + id = fields.Integer() + name = fields.String() + user_ids = fields.List(fields.Integer()) + permission_ids = fields.List(fields.Integer()) + + +class RolesResponseSchema(PermissiveSchema): + count = fields.Integer() + ids = fields.List(fields.Integer()) + result = fields.List(fields.Nested(RoleResponseSchema)) + + guest_token_create_schema = GuestTokenCreateSchema() @@ -172,3 +190,146 @@ class SecurityRestApi(BaseSupersetApi): return self.response_400(message=error.message) except ValidationError as error: return self.response_400(message=error.messages) + + +class RoleRestAPI(BaseSupersetApi): + """ + APIs for listing roles with usersIds and permissionsIds and possibility to update + users of roles + """ + + resource_name = "security/roles" + allow_browser_login = True + openapi_spec_tag = "Security Roles" + openapi_spec_component_schemas = ( + RoleResponseSchema, + RolesResponseSchema, + ) + + @expose("/search/", methods=["GET"]) + @event_logger.log_this + @protect() + @safe + @rison(get_list_schema) + @statsd_metrics + @permission_name("list_roles") + def get_list(self, **kwargs: Any) -> Response: + """ + List roles, including associated user IDs and permission IDs. + + --- + get: + summary: List roles + description: Fetch a paginated list of roles with user and permission IDs. + parameters: + - in: query + name: q + schema: + type: object + properties: + order_column: + type: string + enum: ["id", "name"] + default: "id" + order_direction: + type: string + enum: ["asc", "desc"] + default: "asc" + page: + type: integer + default: 0 + page_size: + type: integer + default: 10 + filters: + type: array + items: + type: object + properties: + col: + type: string + enum: ["user_ids", "permission_ids", "name"] + value: + type: string + responses: + 200: + description: Successfully retrieved roles + content: + application/json: + schema: RolesResponseSchema + 400: + description: Bad request (invalid input) + content: + application/json: + schema: + type: object + properties: + error: + type: string + 403: + description: Forbidden + content: + application/json: + schema: + type: object + properties: + error: + type: string + """ + try: + args = kwargs.get("rison", {}) + order_column = args.get("order_column", "id") + order_direction = args.get("order_direction", "asc") + + valid_columns = ["id", "name"] + if order_column not in valid_columns: + return self.response_400( + message=f"Invalid order column: {order_column}" + ) + + order_by = getattr(Role, order_column) + order_by = asc(order_by) if order_direction == "asc" else desc(order_by) + + page = args.get("page", 0) + page_size = args.get("page_size", 10) + + query = db.session.query(Role).options( + joinedload(Role.permissions), joinedload(Role.user) + ) + + filters = args.get("filters", []) + filter_dict = {f["col"]: f["value"] for f in filters if "col" in f} + + if "user_ids" in filter_dict: + query = query.filter(Role.user.any(id=filter_dict["user_ids"])) + + if "permission_ids" in filter_dict: + query = query.filter( + Role.permissions.any(id=filter_dict["permission_ids"]) + ) + + if "name" in filter_dict: + query = query.filter(Role.name.ilike(f"%{filter_dict['name']}%")) + + roles = ( + query.order_by(order_by).offset(page * page_size).limit(page_size).all() + ) + + return self.response( + 200, + result=[ + { + "id": role.id, + "name": role.name, + "user_ids": [user.id for user in role.user], + "permission_ids": [perm.id for perm in role.permissions], + } + for role in roles + ], + count=query.count(), + ids=[role.id for role in roles], + ) + except ForbiddenError as e: + return self.response_403(message=str(e)) + except Exception as e: + return self.response_500(message=str(e)) diff --git a/superset/security/manager.py b/superset/security/manager.py index 80a1fb957dd6f1d6f9c14e609693c99607b4dbec..009e7f314cbcf2fc2730a37460a574f5891083a4 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -25,6 +25,7 @@ from typing import Any, Callable, cast, NamedTuple, Optional, TYPE_CHECKING from flask import current_app, Flask, g, Request from flask_appbuilder import Model +from flask_appbuilder.security.sqla.apis import RoleApi from flask_appbuilder.security.sqla.manager import SecurityManager from flask_appbuilder.security.sqla.models import ( assoc_group_role, @@ -40,7 +41,6 @@ from flask_appbuilder.security.sqla.models import ( from flask_appbuilder.security.views import ( PermissionModelView, PermissionViewModelView, - RoleModelView, UserModelView, ViewMenuModelView, ) @@ -126,8 +126,19 @@ class SupersetRoleListWidget(ListWidget): # pylint: disable=too-few-public-meth super().__init__(**kwargs) +class SupersetRoleApi(RoleApi): + """ + Overriding the RoleApi to be able to delete roles with permissions + """ + + def pre_delete(self, item: Model) -> None: + """ + Overriding this method to be able to delete items when they have constraints + """ + item.permissions = [] + + UserModelView.list_widget = SupersetSecurityListWidget -RoleModelView.list_widget = SupersetRoleListWidget PermissionViewModelView.list_widget = SupersetSecurityListWidget PermissionModelView.list_widget = SupersetSecurityListWidget @@ -138,15 +149,10 @@ UserModelView.include_route_methods = RouteMethod.CRUD_SET | { RouteMethod.ACTION_POST, "userinfo", } -RoleModelView.include_route_methods = RouteMethod.CRUD_SET PermissionViewModelView.include_route_methods = {RouteMethod.LIST} PermissionModelView.include_route_methods = {RouteMethod.LIST} ViewMenuModelView.include_route_methods = {RouteMethod.LIST} -RoleModelView.list_columns = ["name"] -RoleModelView.edit_columns = ["name", "permissions", "user"] -RoleModelView.related_views = [] - def freeze_value(value: Any) -> str: """ @@ -218,6 +224,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods userstatschartview = None READ_ONLY_MODEL_VIEWS = {"Database", "DynamicPlugin"} + role_api = SupersetRoleApi + USER_MODEL_VIEWS = { "RegisterUserModelView", "UserDBModelView", @@ -251,6 +259,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods "User Registrations", "User's Statistics", # Guarding all AB_ADD_SECURITY_API = True REST APIs + "RoleRestAPI", "Role", "Permission", "PermissionViewMenu", @@ -279,6 +288,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods } ADMIN_ONLY_PERMISSIONS = { + "update_roles_users", + "list_roles", "can_update_role", "all_query_access", "can_grant_guest_token", @@ -1126,7 +1137,9 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods """ Find a List of models by a list of ids, if defined applies `base_filter` """ - query = self.get_session.query(Role).filter(Role.id.in_(role_ids)) + query = self.get_session.query(self.role_model).filter( + self.role_model.id.in_(role_ids) + ) return query.all() def copy_role( @@ -2747,3 +2760,23 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods return current_app.config["AUTH_ROLE_ADMIN"] in [ role.name for role in self.get_user_roles() ] + + # temporal change to remove the roles view from the security menu, + # after migrating all views to frontend, we will set FAB_ADD_SECURITY_VIEWS = False + def register_views(self) -> None: + super().register_views() + + for view in list(self.appbuilder.baseviews): + if ( + isinstance(view, self.rolemodelview.__class__) + and getattr(view, "route_base", None) == "/roles" + ): + self.appbuilder.baseviews.remove(view) + + security_menu = next( + (m for m in self.appbuilder.menu.get_list() if m.name == "Security"), None + ) + if security_menu: + for item in list(security_menu.childs): + if item.name == "List Roles": + security_menu.childs.remove(item) diff --git a/superset/sql_lab.py b/superset/sql_lab.py index 7f4a0f1c991e58a43760eb963628b97419b68751..c157896fcc6eed2072bcfc226aaf52b0dfeb94c7 100644 --- a/superset/sql_lab.py +++ b/superset/sql_lab.py @@ -193,7 +193,7 @@ def get_sql_results( # pylint: disable=too-many-arguments except Exception as ex: # pylint: disable=broad-except logger.debug("Query %d: %s", query_id, ex) stats_logger.incr("error_sqllab_unhandled") - query = get_query(query_id) + query = get_query(query_id=query_id) return handle_query_error(ex, query) @@ -423,7 +423,7 @@ def execute_sql_statements( # noqa: C901 # only asynchronous queries stats_logger.timing("sqllab.query.time_pending", now_as_float() - start_time) - query = get_query(query_id) + query = get_query(query_id=query_id) payload: dict[str, Any] = {"query_id": query_id} database = query.database db_engine_spec = database.db_engine_spec diff --git a/superset/tasks/slack.py b/superset/tasks/slack.py new file mode 100644 index 0000000000000000000000000000000000000000..0b35a721bb5121c4570608d542825e746a44a619 --- /dev/null +++ b/superset/tasks/slack.py @@ -0,0 +1,35 @@ +# 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 logging + +from flask import current_app + +from superset.extensions import celery_app +from superset.utils.slack import get_channels + +logger = logging.getLogger(__name__) + + +@celery_app.task(name="slack.cache_channels") +def cache_channels() -> None: + try: + get_channels( + force=True, cache_timeout=current_app.config["SLACK_CACHE_TIMEOUT"] + ) + except Exception as ex: + logger.exception("An error occurred while caching Slack channels: %s", ex) + raise diff --git a/superset/translations/ar/LC_MESSAGES/messages.po b/superset/translations/ar/LC_MESSAGES/messages.po index 8fd18603d46d7d50803e2df364b590fa2d2ce3b9..38e6732d0e6f2c7a6b07b326ad70e003aa396b0e 100644 --- a/superset/translations/ar/LC_MESSAGES/messages.po +++ b/superset/translations/ar/LC_MESSAGES/messages.po @@ -630,8 +630,8 @@ msgstr ">= (أكبر أو يساوي)" msgid "A Big Number" msgstr "عدد كبير" -msgid "A comma separated list of columns that should be parsed as dates" -msgstr "قائمة مفصولة بفواصل من الأعمدة التي يجب تحليلها كتواريخ" +msgid "Select column names from a dropdown list that should be parsed as dates." +msgstr "حدد أسماء الأعمدة من القائمة المنسدلة التي يجب معاملتها كتواريخ." msgid "A comma-separated list of schemas that files are allowed to upload to." msgstr "قائمة مفصولة بفواصل من المخططات التي يسمح للملفات بالتحميل إليها." diff --git a/superset/translations/de/LC_MESSAGES/messages.po b/superset/translations/de/LC_MESSAGES/messages.po index a6b8ba611da73ae63953eeca1d3c7ccb6f6f26f4..81c65007d6d21408ba9f31d5fb4cf12dc7e9d21c 100644 --- a/superset/translations/de/LC_MESSAGES/messages.po +++ b/superset/translations/de/LC_MESSAGES/messages.po @@ -664,10 +664,10 @@ msgstr ">= (Größer oder gleich)" msgid "A Big Number" msgstr "Eine Große Zahl" -msgid "A comma separated list of columns that should be parsed as dates" +msgid "Select column names from a dropdown list that should be parsed as dates." msgstr "" -"Eine durch Kommas getrennte Liste von Spalten, die als Datumsangaben " -"interpretiert werden sollen" +"Wählen Sie aus der Dropdown-Liste die Namen der Spalten aus, die als Datum " +"interpretiert werden sollen." msgid "A comma-separated list of schemas that files are allowed to upload to." msgstr "" diff --git a/superset/translations/en/LC_MESSAGES/messages.po b/superset/translations/en/LC_MESSAGES/messages.po index e9802f1b7e632a19b1b83d69fb38fd31779b61fa..8b060e4a208b062dd10b6977b67b333667b0d2c5 100644 --- a/superset/translations/en/LC_MESSAGES/messages.po +++ b/superset/translations/en/LC_MESSAGES/messages.po @@ -579,7 +579,7 @@ msgstr "" msgid "A Big Number" msgstr "" -msgid "A comma separated list of columns that should be parsed as dates" +msgid "Select column names from a dropdown list that should be parsed as dates." msgstr "" msgid "A comma-separated list of schemas that files are allowed to upload to." diff --git a/superset/translations/es/LC_MESSAGES/messages.po b/superset/translations/es/LC_MESSAGES/messages.po index 88b49fb2828e2a1ba9cf177ec52c3c5ef4a17a84..748d1fa7ddf7914eb16189e370c5461dd2b542e5 100644 --- a/superset/translations/es/LC_MESSAGES/messages.po +++ b/superset/translations/es/LC_MESSAGES/messages.po @@ -618,9 +618,9 @@ msgid "A Big Number" msgstr "Número Grande" #, fuzzy -msgid "A comma separated list of columns that should be parsed as dates" +msgid "Select column names from a dropdown list that should be parsed as dates." msgstr "" -"Una lista separada por comas de columnas que deben ser parseadas como " +"Seleccione en la lista desplegable los nombres de las columnas que deben analizarse como " "fechas." #, fuzzy diff --git a/superset/translations/fr/LC_MESSAGES/messages.po b/superset/translations/fr/LC_MESSAGES/messages.po index b8724e187af284b31c9d20d450b604a281463f3b..637dc77e96f5a5d35546c1c4cd191b9df1b26ce6 100644 --- a/superset/translations/fr/LC_MESSAGES/messages.po +++ b/superset/translations/fr/LC_MESSAGES/messages.po @@ -624,10 +624,10 @@ msgstr ">= (plus grand ou égal)" msgid "A Big Number" msgstr "Gros nombre" -msgid "A comma separated list of columns that should be parsed as dates" +msgid "Select column names from a dropdown list that should be parsed as dates." msgstr "" -"Une liste de colonnes séparées par des virgules qui doivent être analysées comme " -"des dates" +"Sélectionnez dans la liste déroulante les noms des colonnes à analyser en tant " +"que dates" #, fuzzy msgid "A comma-separated list of schemas that files are allowed to upload to." diff --git a/superset/translations/it/LC_MESSAGES/messages.po b/superset/translations/it/LC_MESSAGES/messages.po index 8458fd95542c23e04a74f74c16f86a41015c3a7b..001dc63c1141ec2ea634fb0f2aa443118873ccc9 100644 --- a/superset/translations/it/LC_MESSAGES/messages.po +++ b/superset/translations/it/LC_MESSAGES/messages.po @@ -601,8 +601,8 @@ msgstr "" msgid "A Big Number" msgstr "Numero Grande" -msgid "A comma separated list of columns that should be parsed as dates" -msgstr "" +msgid "Select column names from a dropdown list that should be parsed as dates." +msgstr "Selezionare i nomi delle colonne da elaborare come date dall'elenco a discesa." msgid "A comma-separated list of schemas that files are allowed to upload to." msgstr "" diff --git a/superset/translations/ja/LC_MESSAGES/messages.po b/superset/translations/ja/LC_MESSAGES/messages.po index f89708311847f71379aba9a9faca52cc2dd32de5..fd9ee19b8edfaca2bec519f923b7948ff65a50c1 100644 --- a/superset/translations/ja/LC_MESSAGES/messages.po +++ b/superset/translations/ja/LC_MESSAGES/messages.po @@ -616,8 +616,8 @@ msgstr ">= (大きいか等しい)" msgid "A Big Number" msgstr "大きな数字" -msgid "A comma separated list of columns that should be parsed as dates" -msgstr "日付として解析する必要がある列のカンマ区切りのリスト" +msgid "Select column names from a dropdown list that should be parsed as dates." +msgstr "日付として解析する列の名前をドロップダウン・リストから選択する。" msgid "A comma-separated list of schemas that files are allowed to upload to." msgstr "ファイルのアップロードを許可するスキーマのカンマ区切りのリスト" diff --git a/superset/translations/ko/LC_MESSAGES/messages.po b/superset/translations/ko/LC_MESSAGES/messages.po index 2c8f1a8f7d4c7384540fd9223c21a06a550d7ea1..bd245cfa076ec9c04d6edea2010628f4d3e34542 100644 --- a/superset/translations/ko/LC_MESSAGES/messages.po +++ b/superset/translations/ko/LC_MESSAGES/messages.po @@ -599,8 +599,8 @@ msgstr "" msgid "A Big Number" msgstr "테이블 명" -msgid "A comma separated list of columns that should be parsed as dates" -msgstr "" +msgid "Select column names from a dropdown list that should be parsed as dates." +msgstr "드롭다운 목록에서 날짜로 처리할 열 이름을 선택합니다." msgid "A comma-separated list of schemas that files are allowed to upload to." msgstr "" diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot index fc9f1c937dcecc5a14045dc3ee3564d0bfd92bf4..e32e2b65976a3d19d342497affc2b31042057d07 100644 --- a/superset/translations/messages.pot +++ b/superset/translations/messages.pot @@ -585,7 +585,7 @@ msgstr "" msgid "A Big Number" msgstr "" -msgid "A comma separated list of columns that should be parsed as dates" +msgid "Select column names from a dropdown list that should be parsed as dates." msgstr "" msgid "A comma-separated list of schemas that files are allowed to upload to." diff --git a/superset/translations/nl/LC_MESSAGES/messages.po b/superset/translations/nl/LC_MESSAGES/messages.po index b7717a7094343124279b9b5c27a08e7ff59e241b..ffdc05c88ba0a0a9cacecf3a0373269b76d0a633 100644 --- a/superset/translations/nl/LC_MESSAGES/messages.po +++ b/superset/translations/nl/LC_MESSAGES/messages.po @@ -629,10 +629,10 @@ msgstr ">= (Groter of gelijk)" msgid "A Big Number" msgstr "Een groot getal" -msgid "A comma separated list of columns that should be parsed as dates" +msgid "Select column names from a dropdown list that should be parsed as dates." msgstr "" -"Een door komma's gescheiden lijst van kolommen die als datums moeten " -"worden geïnterpreteerd" +"Selecteer de kolomnamen die moeten worden behandeld als datums " +"in de vervolgkeuzelijst" msgid "A comma-separated list of schemas that files are allowed to upload to." msgstr "" @@ -3056,9 +3056,8 @@ msgstr "Configuratie" msgid "Configure Advanced Time Range " msgstr "Configureer geavanceerd tijdbereik " -#, fuzzy msgid "Configure Time Range: Current..." -msgstr "Configureer Tijdbereik: Laatste..." +msgstr "Configureer Tijdbereik: Huidige..." msgid "Configure Time Range: Last..." msgstr "Configureer Tijdbereik: Laatste..." @@ -3366,29 +3365,23 @@ msgstr "Valuta voorvoegsel of achtervoegsel" msgid "Currency symbol" msgstr "Valuta symbool" -#, fuzzy msgid "Current" -msgstr "Valuta" +msgstr "Huidig" -#, fuzzy msgid "Current day" -msgstr "Valuta" +msgstr "Huidige dag" -#, fuzzy msgid "Current month" -msgstr "Valuta symbool" +msgstr "Huidige maand" -#, fuzzy msgid "Current quarter" -msgstr "Voer huidige query uit" +msgstr "Huidig kwartaal" -#, fuzzy msgid "Current week" -msgstr "Voer huidige query uit" +msgstr "Huidige week" -#, fuzzy msgid "Current year" -msgstr "Valuta" +msgstr "Huidig jaar" #, python-format msgid "Currently rendered: %s" diff --git a/superset/translations/pl/LC_MESSAGES/messages.po b/superset/translations/pl/LC_MESSAGES/messages.po index 0c22650fb00b49196a5af4c0e1eda3d5505d1cb8..9aca1ac01b13dcb01ef378187947ebb84a1e5a18 100644 --- a/superset/translations/pl/LC_MESSAGES/messages.po +++ b/superset/translations/pl/LC_MESSAGES/messages.po @@ -646,8 +646,8 @@ msgid "A Big Number" msgstr "Duża liczba" #, fuzzy -msgid "A comma separated list of columns that should be parsed as dates" -msgstr "Lista kolumn oddzielonych przecinkami, które powinny być traktowane jako daty." +msgid "Select column names from a dropdown list that should be parsed as dates." +msgstr "Z listy rozwijanej wybierz nazwy kolumn, które mają być traktowane jako daty." #, fuzzy msgid "A comma-separated list of schemas that files are allowed to upload to." diff --git a/superset/translations/pt/LC_MESSAGES/messages.po b/superset/translations/pt/LC_MESSAGES/messages.po index ecd7db908fcdf79dc22c5ebd47e70868505e027e..32e5bc7a54aacc8415012dc80167d5995729e78b 100644 --- a/superset/translations/pt/LC_MESSAGES/messages.po +++ b/superset/translations/pt/LC_MESSAGES/messages.po @@ -606,8 +606,8 @@ msgstr "" msgid "A Big Number" msgstr "Número grande" -msgid "A comma separated list of columns that should be parsed as dates" -msgstr "" +msgid "Select column names from a dropdown list that should be parsed as dates." +msgstr "Selecione os nomes das colunas a serem tratadas como datas na lista pendente." msgid "A comma-separated list of schemas that files are allowed to upload to." msgstr "" diff --git a/superset/translations/pt_BR/LC_MESSAGES/messages.po b/superset/translations/pt_BR/LC_MESSAGES/messages.po index ab06eb013ac16e34a45bd2f957f98772eb24ee57..6aec74f7517afca210b5ab44a8bf7837517ec6a2 100644 --- a/superset/translations/pt_BR/LC_MESSAGES/messages.po +++ b/superset/translations/pt_BR/LC_MESSAGES/messages.po @@ -642,10 +642,10 @@ msgstr ">= (Maior ou equal)" msgid "A Big Number" msgstr "Um grande número" -msgid "A comma separated list of columns that should be parsed as dates" +msgid "Select column names from a dropdown list that should be parsed as dates." msgstr "" -"Uma lista separada por vírgulas de colunas que devem ser analisadas como " -"datas" +"Selecione os nomes das colunas a serem analisadas como datas na lista " +"pendente" msgid "A comma-separated list of schemas that files are allowed to upload to." msgstr "" diff --git a/superset/translations/ru/LC_MESSAGES/messages.po b/superset/translations/ru/LC_MESSAGES/messages.po index e37055b24e569f516fcc356fe9de849eaa57c02e..a4e5996d0a3c31155d6e648eddd614619a3b9619 100644 --- a/superset/translations/ru/LC_MESSAGES/messages.po +++ b/superset/translations/ru/LC_MESSAGES/messages.po @@ -645,10 +645,10 @@ msgstr ">= (больше или равно)" msgid "A Big Number" msgstr "Карточка" -msgid "A comma separated list of columns that should be parsed as dates" +msgid "Select column names from a dropdown list that should be parsed as dates." msgstr "" -"Разделенный запятыми список столбцов, которые должны быть " -"интерпретированы как даты." +"Выберите из выпадающего списка имена столбцов, которые должны быть " +"обработаны как даты" msgid "A comma-separated list of schemas that files are allowed to upload to." msgstr "Разделенный запятыми список схем, в которые можно загружать файлы." diff --git a/superset/translations/sk/LC_MESSAGES/messages.po b/superset/translations/sk/LC_MESSAGES/messages.po index c92d3083d113b6847bdab51b8759cba131664989..d1ce35080bdbd664d249e4605b6b2461de82aa66 100644 --- a/superset/translations/sk/LC_MESSAGES/messages.po +++ b/superset/translations/sk/LC_MESSAGES/messages.po @@ -583,7 +583,7 @@ msgstr "" msgid "A Big Number" msgstr "" -msgid "A comma separated list of columns that should be parsed as dates" +msgid "Select column names from a dropdown list that should be parsed as dates." msgstr "" msgid "A comma-separated list of schemas that files are allowed to upload to." diff --git a/superset/translations/sl/LC_MESSAGES/messages.po b/superset/translations/sl/LC_MESSAGES/messages.po index 55cc6c0a086cf15d0342a8408c69b204f234a694..d0ee1045446d65e99ca160b32645c9c83c127c70 100644 --- a/superset/translations/sl/LC_MESSAGES/messages.po +++ b/superset/translations/sl/LC_MESSAGES/messages.po @@ -681,8 +681,8 @@ msgstr ">= (večje ali enako)" msgid "A Big Number" msgstr "Velika številka" -msgid "A comma separated list of columns that should be parsed as dates" -msgstr "Z vejico ločen seznam stolpcev, v katerih bodo prepoznani datumi" +msgid "Select column names from a dropdown list that should be parsed as dates." +msgstr "S spustnega seznama izberite imena stolpcev, ki bodo obravnavani kot datumi." msgid "A comma-separated list of schemas that files are allowed to upload to." msgstr "Z vejicami ločen seznam shem, kjer je dovoljeno nalaganje datotek." diff --git a/superset/translations/tr/LC_MESSAGES/messages.po b/superset/translations/tr/LC_MESSAGES/messages.po index 0fadea94f0d095b31cf19ffe0c5fe96a90359a71..c51c4b1e910baaa171ac3355123aaa8f4ee3b1b7 100644 --- a/superset/translations/tr/LC_MESSAGES/messages.po +++ b/superset/translations/tr/LC_MESSAGES/messages.po @@ -581,8 +581,8 @@ msgstr "" msgid "A Big Number" msgstr "" -msgid "A comma separated list of columns that should be parsed as dates" -msgstr "" +msgid "Select column names from a dropdown list that should be parsed as dates." +msgstr "Açılır listeden tarih olarak değerlendirilecek sütun adlarını seçin." msgid "A comma-separated list of schemas that files are allowed to upload to." msgstr "" diff --git a/superset/translations/uk/LC_MESSAGES/messages.po b/superset/translations/uk/LC_MESSAGES/messages.po index e62117c9a59d5c5b59bc7783181749fd59d179cf..235335e2f872bd0b87bfffd9b934a255d8e86a53 100644 --- a/superset/translations/uk/LC_MESSAGES/messages.po +++ b/superset/translations/uk/LC_MESSAGES/messages.po @@ -634,8 +634,8 @@ msgstr "> = (Більший або рівний)" msgid "A Big Number" msgstr "Велика кількість" -msgid "A comma separated list of columns that should be parsed as dates" -msgstr "Кома -розділений список стовпців, які слід проаналізувати як дати" +msgid "Select column names from a dropdown list that should be parsed as dates." +msgstr "Виберіть імена стовпців зі списку, що випадає, які слід розібрати як дати." msgid "A comma-separated list of schemas that files are allowed to upload to." msgstr "Список схем, відокремлений комами, до яких файли дозволяють завантажувати." diff --git a/superset/translations/zh/LC_MESSAGES/messages.po b/superset/translations/zh/LC_MESSAGES/messages.po index 9b96fb8236568de0fe502d624bedb18515f3f52b..4128f99c9096e941ef5f253e02dfe1e0f56dc70a 100644 --- a/superset/translations/zh/LC_MESSAGES/messages.po +++ b/superset/translations/zh/LC_MESSAGES/messages.po @@ -621,8 +621,8 @@ msgid "A Big Number" msgstr "大数字" #, fuzzy -msgid "A comma separated list of columns that should be parsed as dates" -msgstr "应作为日期解析的列的逗号分隔列表。" +msgid "Select column names from a dropdown list that should be parsed as dates." +msgstr "从下拉列表中选择要作为日期处理的列名称。" #, fuzzy msgid "A comma-separated list of schemas that files are allowed to upload to." diff --git a/superset/translations/zh_TW/LC_MESSAGES/messages.po b/superset/translations/zh_TW/LC_MESSAGES/messages.po index a76e95acd1d67f262421518d7c52afd972ebefdc..1fe130e01f6aebf50d622b1d5150b0fcac34d1e8 100644 --- a/superset/translations/zh_TW/LC_MESSAGES/messages.po +++ b/superset/translations/zh_TW/LC_MESSAGES/messages.po @@ -620,8 +620,8 @@ msgid "A Big Number" msgstr "大數字" #, fuzzy -msgid "A comma separated list of columns that should be parsed as dates" -msgstr "應作為日期解析的列的逗號分隔列表。" +msgid "Select column names from a dropdown list that should be parsed as dates." +msgstr "从下拉列表中选择要分析的日期列名称。" #, fuzzy msgid "A comma-separated list of schemas that files are allowed to upload to." diff --git a/superset/utils/excel.py b/superset/utils/excel.py index d34446832a8cc293fae81a1f011e30c9b4c29fe1..46e1a1f071aca08b6f34ffbe72c3757406ddc4cd 100644 --- a/superset/utils/excel.py +++ b/superset/utils/excel.py @@ -56,10 +56,24 @@ def df_to_excel(df: pd.DataFrame, **kwargs: Any) -> Any: def apply_column_types( df: pd.DataFrame, column_types: list[GenericDataType] ) -> pd.DataFrame: + """ + Applies the column types to the dataframe to prepare for an excel export + + :param df: The dataframe to apply the column types to + :param column_types: The types of the columns + :return: The dataframe with the column types applied + """ for column, column_type in zip(df.columns, column_types, strict=False): if column_type == GenericDataType.NUMERIC: try: df[column] = pd.to_numeric(df[column]) + # if the number is too large, convert it to a string + # Excel does not support numbers larger than 10^15 + df[column] = df[column].apply( + lambda x: str(x) + if isinstance(x, (int, float)) and abs(x) > 10**15 + else x + ) except ValueError: df[column] = df[column].astype(str) elif pd.api.types.is_datetime64tz_dtype(df[column]): diff --git a/superset/utils/screenshots.py b/superset/utils/screenshots.py index 74a1f0746fea1bcaf12f2149da195f49d8dd8865..cf28dcf916c02a898809004e0f78531f06b0c494 100644 --- a/superset/utils/screenshots.py +++ b/superset/utils/screenshots.py @@ -26,7 +26,6 @@ from typing import cast, TYPE_CHECKING, TypedDict from flask import current_app from superset import app, feature_flag_manager, thumbnail_cache -from superset.dashboards.permalink.types import DashboardPermalinkState from superset.extensions import event_logger from superset.utils.hashing import md5_sha_from_dict from superset.utils.urls import modify_url_query @@ -349,7 +348,7 @@ class DashboardScreenshot(BaseScreenshot): self, window_size: bool | WindowSize | None = None, thumb_size: bool | WindowSize | None = None, - dashboard_state: DashboardPermalinkState | None = None, + permalink_key: str | None = None, ) -> str: window_size = window_size or self.window_size thumb_size = thumb_size or self.thumb_size @@ -359,6 +358,6 @@ class DashboardScreenshot(BaseScreenshot): "type": "thumb", "window_size": window_size, "thumb_size": thumb_size, - "dashboard_state": dashboard_state, + "permalink_key": permalink_key, } return md5_sha_from_dict(args) diff --git a/superset/utils/slack.py b/superset/utils/slack.py index 8125a3ac4019962b798a04f2fef00cf4d4280dad..34d48bef21b1006195e654aef22c3368af642932 100644 --- a/superset/utils/slack.py +++ b/superset/utils/slack.py @@ -17,7 +17,7 @@ import logging -from typing import Any, Optional +from typing import Callable, Optional from flask import current_app from slack_sdk import WebClient @@ -60,7 +60,7 @@ def get_slack_client() -> WebClient: key="slack_conversations_list", cache=cache_manager.cache, ) -def get_channels(limit: int, extra_params: dict[str, Any]) -> list[SlackChannelSchema]: +def get_channels() -> list[SlackChannelSchema]: """ Retrieves a list of all conversations accessible by the bot from the Slack API, and caches results (to avoid rate limits). @@ -71,11 +71,12 @@ def get_channels(limit: int, extra_params: dict[str, Any]) -> list[SlackChannelS client = get_slack_client() channel_schema = SlackChannelSchema() channels: list[SlackChannelSchema] = [] + extra_params = {"types": ",".join(SlackChannelTypes)} cursor = None while True: response = client.conversations_list( - limit=limit, cursor=cursor, exclude_archived=True, **extra_params + limit=999, cursor=cursor, exclude_archived=True, **extra_params ) channels.extend( channel_schema.load(channel) for channel in response.data["channels"] @@ -89,7 +90,6 @@ def get_channels(limit: int, extra_params: dict[str, Any]) -> list[SlackChannelS def get_channels_with_search( search_string: str = "", - limit: int = 999, types: Optional[list[SlackChannelTypes]] = None, exact_match: bool = False, force: bool = False, @@ -99,18 +99,25 @@ def get_channels_with_search( all channels and filter them ourselves This will search by slack name or id """ - extra_params = {} - extra_params["types"] = ",".join(types) if types else None try: channels = get_channels( - limit=limit, - extra_params=extra_params, force=force, - cache_timeout=86400, + cache_timeout=current_app.config["SLACK_CACHE_TIMEOUT"], ) except (SlackClientError, SlackApiError) as ex: raise SupersetException(f"Failed to list channels: {ex}") from ex + if types and not len(types) == len(SlackChannelTypes): + conditions: list[Callable[[SlackChannelSchema], bool]] = [] + if SlackChannelTypes.PUBLIC in types: + conditions.append(lambda channel: not channel["is_private"]) + if SlackChannelTypes.PRIVATE in types: + conditions.append(lambda channel: channel["is_private"]) + + channels = [ + channel for channel in channels if any(cond(channel) for cond in conditions) + ] + # The search string can be multiple channels separated by commas if search_string: search_array = recipients_string_to_list(search_string) diff --git a/superset/views/base.py b/superset/views/base.py index fe4ec0e0ab8d45495beec211faa2b1360b1a054a..5b7ef99b0264ee84f226e4eb908da0ba6c79f2a1 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -319,7 +319,10 @@ def cached_common_bootstrap_data( # pylint: disable=unused-argument # verify client has google sheets installed available_specs = get_available_engine_specs() - frontend_config["HAS_GSHEETS_INSTALLED"] = bool(available_specs[GSheetsEngineSpec]) + frontend_config["HAS_GSHEETS_INSTALLED"] = ( + GSheetsEngineSpec in available_specs + and bool(available_specs[GSheetsEngineSpec]) + ) language = locale.language if locale else "en" diff --git a/superset/views/roles.py b/superset/views/roles.py new file mode 100644 index 0000000000000000000000000000000000000000..29f293f51e578f745a35be03784236930aeab93d --- /dev/null +++ b/superset/views/roles.py @@ -0,0 +1,34 @@ +# 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. +from flask_appbuilder import permission_name +from flask_appbuilder.api import expose +from flask_appbuilder.security.decorators import has_access + +from superset.superset_typing import FlaskResponse + +from .base import BaseSupersetView + + +class RolesListView(BaseSupersetView): + route_base = "/" + class_permission_name = "security" + + @expose("/roles/") + @has_access + @permission_name("read") + def list(self) -> FlaskResponse: + return super().render_app_template() diff --git a/tests/integration_tests/charts/api_tests.py b/tests/integration_tests/charts/api_tests.py index 5fd27bed0f864b780f5c065f951cd106bfad657c..d4e8f01993ec639568ea7175ee6304f4967478b7 100644 --- a/tests/integration_tests/charts/api_tests.py +++ b/tests/integration_tests/charts/api_tests.py @@ -14,7 +14,6 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -"""Unit tests for Superset""" from io import BytesIO from unittest import mock @@ -24,7 +23,6 @@ from zipfile import is_zipfile, ZipFile import prison import pytest import yaml -from flask import g from flask_babel import lazy_gettext as _ from parameterized import parameterized from sqlalchemy import and_ @@ -63,7 +61,6 @@ from tests.integration_tests.fixtures.importexport import ( dataset_config, dataset_metadata_config, ) -from tests.integration_tests.fixtures.query_context import get_query_context from tests.integration_tests.fixtures.tags import ( create_custom_tags, # noqa: F401 get_filter_params, @@ -80,7 +77,6 @@ from tests.integration_tests.insert_chart_mixin import InsertChartMixin from tests.integration_tests.test_app import app from tests.integration_tests.utils.get_dashboards import get_dashboards_ids -CHART_DATA_URI = "api/v1/chart/data" CHARTS_FIXTURE_COUNT = 10 @@ -2330,57 +2326,3 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase): security_manager.add_permission_role(alpha_role, write_tags_perm) security_manager.add_permission_role(alpha_role, tag_charts_perm) - - @patch("superset.security.manager.SupersetSecurityManager.has_guest_access") - @patch("superset.security.manager.SupersetSecurityManager.is_guest_user") - @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") - def test_get_chart_data_as_guest_user( - self, is_guest_user, has_guest_access - ): # get_guest_rls_filters - """ - Chart API: Test create simple chart - """ - self.login(ADMIN_USERNAME) - g.user.rls = [] - is_guest_user.return_value = True - has_guest_access.return_value = True - - with mock.patch.object(Slice, "get_query_context") as mock_get_query_context: - mock_get_query_context.return_value = get_query_context("birth_names") - rv = self.client.post( - "api/v1/chart/data", # noqa: F541 - json={ - "datasource": {"id": 2, "type": "table"}, - "queries": [ - { - "extras": {"where": "", "time_grain_sqla": "P1D"}, - "columns": ["name"], - "metrics": [{"label": "sum__num"}], - "orderby": [("sum__num", False)], - "row_limit": 100, - "granularity": "ds", - "time_range": "100 years ago : now", - "timeseries_limit": 0, - "timeseries_limit_metric": None, - "order_desc": True, - "filters": [ - {"col": "gender", "op": "==", "val": "boy"}, - {"col": "num", "op": "IS NOT NULL"}, - { - "col": "name", - "op": "NOT IN", - "val": ["<NULL>", '"abc"'], - }, - ], - "having": "", - "where": "", - } - ], - "result_format": "json", - "result_type": "full", - }, - ) - data = json.loads(rv.data.decode("utf-8")) - result = data["result"] - excluded_key = "query" - assert all([excluded_key not in query for query in result]) # noqa: C419 diff --git a/tests/integration_tests/charts/data/api_tests.py b/tests/integration_tests/charts/data/api_tests.py index dd9480c124eb50afe8d204767179c18ddb38b641..fa5e7b44ba2b4502e217bf1537c71cb40a13c71b 100644 --- a/tests/integration_tests/charts/data/api_tests.py +++ b/tests/integration_tests/charts/data/api_tests.py @@ -14,25 +14,45 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -# isort:skip_file -"""Unit tests for Superset""" -import unittest import copy +import time +import unittest from datetime import datetime from io import BytesIO -import time from typing import Any, Optional from unittest import mock from zipfile import ZipFile -from flask import Response +import pytest +from flask import g, Response from flask.ctx import AppContext -from tests.integration_tests.conftest import with_feature_flags + from superset.charts.data.api import ChartDataRestApi +from superset.commands.chart.data.get_data_command import ChartDataCommand +from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType +from superset.connectors.sqla.models import SqlaTable, TableColumn +from superset.errors import SupersetErrorType +from superset.extensions import async_query_manager_factory, db +from superset.models.annotations import AnnotationLayer +from superset.models.slice import Slice from superset.models.sql_lab import Query +from superset.superset_typing import AdhocColumn +from superset.utils import json +from superset.utils.core import ( + AdhocMetricExpressionType, + AnnotationType, + backend, + ExtraFiltersReasonType, + get_example_default_schema, +) +from superset.utils.database import get_example_database, get_main_database +from tests.common.query_context_generator import ANNOTATION_LAYERS +from tests.integration_tests.annotation_layers.fixtures import ( + create_annotation_layers, # noqa: F401 +) from tests.integration_tests.base_tests import SupersetTestCase, test_client -from tests.integration_tests.annotation_layers.fixtures import create_annotation_layers # noqa: F401 +from tests.integration_tests.conftest import with_feature_flags from tests.integration_tests.constants import ( ADMIN_USERNAME, GAMMA_NO_CSV_USERNAME, @@ -42,37 +62,13 @@ from tests.integration_tests.fixtures.birth_names_dashboard import ( load_birth_names_dashboard_with_slices, # noqa: F401 load_birth_names_data, # noqa: F401 ) -from tests.integration_tests.test_app import app from tests.integration_tests.fixtures.energy_dashboard import ( - load_energy_table_with_slice, # noqa: F401 load_energy_table_data, # noqa: F401 + load_energy_table_with_slice, # noqa: F401 ) -import pytest -from superset.models.slice import Slice - -from superset.commands.chart.data.get_data_command import ChartDataCommand -from superset.connectors.sqla.models import TableColumn, SqlaTable -from superset.errors import SupersetErrorType -from superset.extensions import async_query_manager_factory, db -from superset.models.annotations import AnnotationLayer -from superset.superset_typing import AdhocColumn -from superset.utils.core import ( - AnnotationType, - backend, - get_example_default_schema, - AdhocMetricExpressionType, - ExtraFiltersReasonType, -) -from superset.utils import json -from superset.utils.database import get_example_database, get_main_database -from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType - -from tests.common.query_context_generator import ANNOTATION_LAYERS from tests.integration_tests.fixtures.query_context import get_query_context - from tests.integration_tests.test_app import app # noqa: F811 - CHART_DATA_URI = "api/v1/chart/data" CHARTS_FIXTURE_COUNT = 10 ADHOC_COLUMN_FIXTURE: AdhocColumn = { @@ -1286,6 +1282,58 @@ class TestGetChartDataApi(BaseTestChartDataApi): } ] + @mock.patch("superset.security.manager.SupersetSecurityManager.has_guest_access") + @mock.patch("superset.security.manager.SupersetSecurityManager.is_guest_user") + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") + def test_chart_data_as_guest_user(self, is_guest_user, has_guest_access): + """ + Chart data API: Test response does not inlcude the SQL query for embedded + users. + """ + g.user.rls = [] + is_guest_user.return_value = True + has_guest_access.return_value = True + + rv = self.client.post(CHART_DATA_URI, json=self.query_context_payload) + data = json.loads(rv.data.decode("utf-8")) + result = data["result"] + excluded_key = "query" + assert all([excluded_key not in query for query in result]) # noqa: C419 + + def test_chart_data_table_chart_with_time_grain_filter(self): + """ + Chart data API: Test that a table chart that's not using a temporal column can + still receive a time grain filter (for Jinja purposes). + """ + metric_def = { + "aggregate": None, + "column": None, + "datasourceWarning": False, + "expressionType": "SQL", + "hasCustomLabel": True, + "label": "test", + "optionName": "metric_1eef4v0fryc_m7tm09g1hu", + "sqlExpression": "'{{ time_grain }}'", + } + self.query_context_payload["queries"][0]["columns"] = [] + self.query_context_payload["queries"][0]["metrics"] = [metric_def] + self.query_context_payload["queries"][0]["row_limit"] = 1 + self.query_context_payload["queries"][0]["extras"] = { + "where": "", + "having": "", + "time_grain_sqla": "PT5M", + } + self.query_context_payload["queries"][0]["orderby"] = [[metric_def, True]] + del self.query_context_payload["queries"][0]["granularity"] + del self.query_context_payload["queries"][0]["time_range"] + self.query_context_payload["queries"][0]["filters"] = [] + + rv = self.client.post(CHART_DATA_URI, json=self.query_context_payload) + data = json.loads(rv.data.decode("utf-8")) + result = data["result"][0] + assert "PT5M" in result["query"] + assert result["data"] == [{"test": "PT5M"}] + @pytest.fixture def physical_query_context(physical_dataset) -> dict[str, Any]: diff --git a/tests/integration_tests/core_tests.py b/tests/integration_tests/core_tests.py index 6b28ac13bfdd3d38e13414a1cc2be56eacb61d48..8fe424b1bdfc4ef8b7ef73db55bfcc8b2fe6ea83 100644 --- a/tests/integration_tests/core_tests.py +++ b/tests/integration_tests/core_tests.py @@ -161,7 +161,7 @@ class TestCore(SupersetTestCase): role = security_manager.find_role(role_name) view_menus = [p.view_menu.name for p in role.permissions] assert_func("ResetPasswordView", view_menus) - assert_func("RoleModelView", view_menus) + assert_func("RoleRestAPI", view_menus) assert_func("Security", view_menus) assert_func("SQL Lab", view_menus) diff --git a/tests/integration_tests/dashboards/api_tests.py b/tests/integration_tests/dashboards/api_tests.py index 56062dc85272309b6efd7e71892b7e039046265f..291096deb69de4be2ab4ca750d6e4038889da6f9 100644 --- a/tests/integration_tests/dashboards/api_tests.py +++ b/tests/integration_tests/dashboards/api_tests.py @@ -3038,6 +3038,18 @@ class TestDashboardApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCas response = self._cache_screenshot(dashboard.id) assert response.status_code == 202 + @with_feature_flags(THUMBNAILS=True, ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS=True) + @pytest.mark.usefixtures("create_dashboard_with_tag") + def test_cache_dashboard_screenshot_success_permalink_payload(self): + self.login(ADMIN_USERNAME) + dashboard = ( + db.session.query(Dashboard) + .filter(Dashboard.dashboard_title == "dash with tag") + .first() + ) + response = self._cache_screenshot(dashboard.id, {"permalinkKey": "1234"}) + assert response.status_code == 202 + @with_feature_flags(THUMBNAILS=True, ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS=True) @pytest.mark.usefixtures("create_dashboard_with_tag") def test_cache_dashboard_screenshot_dashboard_validation(self): diff --git a/tests/integration_tests/db_engine_specs/bigquery_tests.py b/tests/integration_tests/db_engine_specs/bigquery_tests.py index 45edb5a0d7c43e6110f345f7d07b40d3274a698f..636fc3523ae36ce902da1806e46c1bebbbb759f1 100644 --- a/tests/integration_tests/db_engine_specs/bigquery_tests.py +++ b/tests/integration_tests/db_engine_specs/bigquery_tests.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. import unittest.mock as mock +from contextlib import contextmanager import pytest from pandas import DataFrame @@ -32,6 +33,15 @@ from tests.integration_tests.fixtures.birth_names_dashboard import ( ) +@contextmanager +def mock_engine_with_credentials(*args, **kwargs): + engine_mock = mock.Mock() + engine_mock.dialect.credentials_info = { + "key": "value" + } # Add the credentials_info attribute + yield engine_mock + + class TestBigQueryDbEngineSpec(TestDbEngineSpec): def test_bigquery_sqla_column_label(self): """ @@ -111,108 +121,45 @@ class TestBigQueryDbEngineSpec(TestDbEngineSpec): result = BigQueryEngineSpec.fetch_data(None, 0) assert result == [1, 2] - def test_get_extra_table_metadata(self): + @mock.patch.object( + BigQueryEngineSpec, "get_engine", side_effect=mock_engine_with_credentials + ) + @mock.patch.object(BigQueryEngineSpec, "get_time_partition_column") + @mock.patch.object(BigQueryEngineSpec, "get_max_partition_id") + @mock.patch.object(BigQueryEngineSpec, "quote_table", return_value="`table_name`") + def test_get_extra_table_metadata( + self, + mock_quote_table, + mock_get_max_partition_id, + mock_get_time_partition_column, + mock_get_engine, + ): """ DB Eng Specs (bigquery): Test extra table metadata """ database = mock.Mock() + sql = "SELECT * FROM `table_name`" + database.compile_sqla_query.return_value = sql + tbl = Table("some_table", "some_schema") + # Test no indexes - database.get_indexes = mock.MagicMock(return_value=None) - result = BigQueryEngineSpec.get_extra_table_metadata( - database, - Table("some_table", "some_schema"), - ) + mock_get_time_partition_column.return_value = None + mock_get_max_partition_id.return_value = None + result = BigQueryEngineSpec.get_extra_table_metadata(database, tbl) assert result == {} - index_metadata = [ - { - "name": "clustering", - "column_names": ["c_col1", "c_col2", "c_col3"], - }, - { - "name": "partition", - "column_names": ["p_col1", "p_col2", "p_col3"], + mock_get_time_partition_column.return_value = "ds" + mock_get_max_partition_id.return_value = "19690101" + result = BigQueryEngineSpec.get_extra_table_metadata(database, tbl) + print(result) + assert result == { + "indexes": [{"cols": ["ds"], "name": "partitioned", "type": "partitioned"}], + "partitions": { + "cols": ["ds"], + "latest": {"ds": "19690101"}, + "partitionQuery": sql, }, - ] - expected_result = { - "partitions": {"cols": [["p_col1", "p_col2", "p_col3"]]}, - "clustering": {"cols": [["c_col1", "c_col2", "c_col3"]]}, } - database.get_indexes = mock.MagicMock(return_value=index_metadata) - result = BigQueryEngineSpec.get_extra_table_metadata( - database, - Table("some_table", "some_schema"), - ) - assert result == expected_result - - def test_get_indexes(self): - database = mock.Mock() - inspector = mock.Mock() - schema = "foo" - table_name = "bar" - - inspector.get_indexes = mock.Mock( - return_value=[ - { - "name": "partition", - "column_names": [None], - "unique": False, - } - ] - ) - - assert ( - BigQueryEngineSpec.get_indexes( - database, - inspector, - Table(table_name, schema), - ) - == [] - ) - - inspector.get_indexes = mock.Mock( - return_value=[ - { - "name": "partition", - "column_names": ["dttm"], - "unique": False, - } - ] - ) - - assert BigQueryEngineSpec.get_indexes( - database, - inspector, - Table(table_name, schema), - ) == [ - { - "name": "partition", - "column_names": ["dttm"], - "unique": False, - } - ] - - inspector.get_indexes = mock.Mock( - return_value=[ - { - "name": "partition", - "column_names": ["dttm", None], - "unique": False, - } - ] - ) - - assert BigQueryEngineSpec.get_indexes( - database, - inspector, - Table(table_name, schema), - ) == [ - { - "name": "partition", - "column_names": ["dttm"], - "unique": False, - } - ] @mock.patch("superset.db_engine_specs.bigquery.BigQueryEngineSpec.get_engine") @mock.patch("superset.db_engine_specs.bigquery.pandas_gbq") diff --git a/tests/integration_tests/fixtures/importexport.py b/tests/integration_tests/fixtures/importexport.py index 427d0d24a69776424777296835ee9006ff49ef97..33aff24541cfef6bf18d3c0d95da2f12473f08d5 100644 --- a/tests/integration_tests/fixtures/importexport.py +++ b/tests/integration_tests/fixtures/importexport.py @@ -689,4 +689,5 @@ saved_queries_config = { "uuid": "05b679b5-8eaf-452c-b874-a7a774cfa4e9", "version": "1.0.0", "database_uuid": "b8a1ccd3-779d-4ab7-8ad8-9ab119d7fe89", + "catalog": "default", } diff --git a/tests/integration_tests/security/api_tests.py b/tests/integration_tests/security/api_tests.py index 3bb85c4cad40fd1bdf9c515a5e092c734c623497..5667c64ca1101080ebf0d1aa77fc1eb2d0d07758 100644 --- a/tests/integration_tests/security/api_tests.py +++ b/tests/integration_tests/security/api_tests.py @@ -219,6 +219,7 @@ class TestSecurityGuestTokenApiTokenValidator(SupersetTestCase): class TestSecurityRolesApi(SupersetTestCase): uri = "api/v1/security/roles/" # noqa: F541 + show_uri = "api/v1/security/roles/search/" @with_config({"FAB_ADD_SECURITY_API": True}) def test_get_security_roles_admin(self): @@ -276,3 +277,19 @@ class TestSecurityRolesApi(SupersetTestCase): content_type="application/json", ) self.assert403(response) + + def test_show_roles_admin(self): + """ + Security API: Admin should be able to show roles with permissions and users + """ + self.login(ADMIN_USERNAME) + response = self.client.get(self.show_uri) + self.assert200(response) + + def test_show_roles_gamma(self): + """ + Security API: Gamma should not be able to show roles + """ + self.login(GAMMA_USERNAME) + response = self.client.get(self.show_uri) + self.assert403(response) diff --git a/tests/integration_tests/sqla_models_tests.py b/tests/integration_tests/sqla_models_tests.py index c3f4328bd66f2e2da3022b31252500cf4f737794..80ada22e0aac323756d651c1adc9c1e95bdd65ba 100644 --- a/tests/integration_tests/sqla_models_tests.py +++ b/tests/integration_tests/sqla_models_tests.py @@ -27,6 +27,7 @@ import pytest import numpy as np import pandas as pd from flask.ctx import AppContext +from flask_appbuilder.security.sqla.models import Role from pytest_mock import MockerFixture from sqlalchemy.sql import text from sqlalchemy.sql.elements import TextClause @@ -797,9 +798,10 @@ def test_none_operand_in_filter(login_as_admin, physical_dataset): SELECT '{{ current_user_id() }}' as id, '{{ current_username() }}' as username, - '{{ current_user_email() }}' as email + '{{ current_user_email() }}' as email, + '{{ current_user_roles()|tojson }}' as roles """, - {1, "abc", "abc@test.com"}, + {1, "abc", "abc@test.com", '["role1", "role2"]'}, True, ), ( @@ -809,9 +811,10 @@ def test_none_operand_in_filter(login_as_admin, physical_dataset): SELECT '{{ current_user_id() }}' as id, '{{ current_username() }}' as username, - '{{ user_email }}' as email + '{{ user_email }}' as email, + '{{ current_user_roles()|tojson }}' as roles """, - {1, "abc", "abc@test.com"}, + {1, "abc", "abc@test.com", '["role1", "role2"]'}, True, ), ( @@ -830,7 +833,8 @@ def test_none_operand_in_filter(login_as_admin, physical_dataset): SELECT '{{ current_user_id(False) }}' as id, '{{ current_username(False) }}' as username, - '{{ current_user_email(False) }}' as email + '{{ current_user_email(False) }}' as email, + '{{ current_user_roles(False)|tojson }}' as roles """, [], True, @@ -841,7 +845,12 @@ def test_none_operand_in_filter(login_as_admin, physical_dataset): @patch("superset.jinja_context.get_user_id", return_value=1) @patch("superset.jinja_context.get_username", return_value="abc") @patch("superset.jinja_context.get_user_email", return_value="abc@test.com") +@patch( + "superset.jinja_context.security_manager.get_user_roles", + return_value=[Role(name="role1"), Role(name="role2")], +) def test_extra_cache_keys( + mock_get_user_roles, mock_user_email, mock_username, mock_user_id, @@ -883,7 +892,12 @@ def test_extra_cache_keys( @patch("superset.jinja_context.get_user_id", return_value=1) @patch("superset.jinja_context.get_username", return_value="abc") @patch("superset.jinja_context.get_user_email", return_value="abc@test.com") +@patch( + "superset.jinja_context.security_manager.get_user_roles", + return_value=[Role(name="role1"), Role(name="role2")], +) def test_extra_cache_keys_in_sql_expression( + mock_get_user_roles, mock_user_email, mock_username, mock_user_id, diff --git a/tests/unit_tests/charts/commands/importers/v1/import_test.py b/tests/unit_tests/charts/commands/importers/v1/import_test.py index 56c4658a84bca88be6a47de9a4363a121a0a0c1f..42a4afb779cf5e5360cfd36951e8973ceac762ac 100644 --- a/tests/unit_tests/charts/commands/importers/v1/import_test.py +++ b/tests/unit_tests/charts/commands/importers/v1/import_test.py @@ -18,8 +18,10 @@ import copy from collections.abc import Generator +from unittest.mock import patch import pytest +import yaml from flask_appbuilder.security.sqla.models import Role, User from pytest_mock import MockerFixture from sqlalchemy.orm.session import Session @@ -27,8 +29,11 @@ from sqlalchemy.orm.session import Session from superset import security_manager from superset.commands.chart.importers.v1.utils import import_chart from superset.commands.exceptions import ImportFailedError +from superset.commands.importers.v1.utils import import_tag from superset.connectors.sqla.models import Database, SqlaTable +from superset.extensions import feature_flag_manager from superset.models.slice import Slice +from superset.tags.models import TaggedObject from superset.utils.core import override_user from tests.integration_tests.fixtures.importexport import chart_config @@ -280,3 +285,43 @@ def test_import_existing_chart_with_permission( # Assert that the can write to chart was checked mock_can_access.assert_called_once_with("can_write", "Chart") mock_can_access_chart.assert_called_once_with(slice) + + +def test_import_tag_logic_for_charts(session_with_schema: Session): + contents = { + "tags.yaml": yaml.dump( + {"tags": [{"tag_name": "tag_1", "description": "Description for tag_1"}]} + ) + } + + object_id = 1 + object_type = "chart" + + with patch.object(feature_flag_manager, "is_feature_enabled", return_value=True): + new_tag_ids = import_tag( + ["tag_1"], contents, object_id, object_type, session_with_schema + ) + assert len(new_tag_ids) > 0 + assert ( + session_with_schema.query(TaggedObject) + .filter_by(object_id=object_id, object_type=object_type) + .count() + > 0 + ) + + session_with_schema.query(TaggedObject).filter_by( + object_id=object_id, object_type=object_type + ).delete() + session_with_schema.commit() + + with patch.object(feature_flag_manager, "is_feature_enabled", return_value=False): + new_tag_ids_disabled = import_tag( + ["tag_1"], contents, object_id, object_type, session_with_schema + ) + assert len(new_tag_ids_disabled) == 0 + associated_tags = ( + session_with_schema.query(TaggedObject) + .filter_by(object_id=object_id, object_type=object_type) + .all() + ) + assert len(associated_tags) == 0 diff --git a/tests/unit_tests/commands/dashboard/__init__.py b/tests/unit_tests/commands/dashboard/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..13a83393a9124bf6ec36540556b4808abd47e206 --- /dev/null +++ b/tests/unit_tests/commands/dashboard/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/tests/unit_tests/commands/dashboard/create_test.py b/tests/unit_tests/commands/dashboard/create_test.py new file mode 100644 index 0000000000000000000000000000000000000000..9bb0cff9ab9c92222eacd0caf9c6f15d353f7499 --- /dev/null +++ b/tests/unit_tests/commands/dashboard/create_test.py @@ -0,0 +1,98 @@ +# 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. +from __future__ import annotations + +import pytest +from flask_appbuilder.security.sqla.models import Role, User +from pytest_mock import MockerFixture + +from superset.commands.dashboard.create import CreateDashboardCommand +from superset.extensions import security_manager +from tests.unit_tests.fixtures.common import admin_user, after_each # noqa: F401 + + +def test_validate_custom_role_class( + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + admin_user: User, # noqa: F811 + after_each: None, # noqa: F811 +) -> None: + class CustomRoleModel(Role): + __tablename__ = "ab_role" + + monkeypatch.setattr(security_manager, "role_model", CustomRoleModel) + mocker.patch.object(security_manager, "is_admin", return_value=True) + + owner_ids = [admin_user.id] + role_ids = [role.id for role in admin_user.roles] + + command = CreateDashboardCommand(data={"owners": owner_ids, "roles": role_ids}) + command.validate() + + for role in command._properties["roles"]: + assert isinstance(role, CustomRoleModel) + + +def test_validate_custom_user_class( + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + admin_user: User, # noqa: F811 + after_each: None, # noqa: F811 +) -> None: + class CustomUserModel(User): + __tablename__ = "ab_user" + + monkeypatch.setattr(security_manager, "user_model", CustomUserModel) + mocker.patch.object(security_manager, "is_admin", return_value=True) + + owner_ids = [admin_user.id] + role_ids = [role.id for role in admin_user.roles] + + command = CreateDashboardCommand(data={"owners": owner_ids, "roles": role_ids}) + command.validate() + + for owner in command._properties["owners"]: + assert isinstance(owner, CustomUserModel) + + +def test_validate_custom_role_and_user_class( + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + admin_user: User, # noqa: F811 + after_each: None, # noqa: F811 +) -> None: + class CustomRoleModel(Role): + __tablename__ = "ab_role" + + class CustomUserModel(User): + __tablename__ = "ab_user" + + monkeypatch.setattr(security_manager, "role_model", CustomRoleModel) + monkeypatch.setattr(security_manager, "user_model", CustomUserModel) + mocker.patch.object(security_manager, "is_admin", return_value=True) + + owner_ids = [admin_user.id] + role_ids = [role.id for role in admin_user.roles] + + command = CreateDashboardCommand(data={"owners": owner_ids, "roles": role_ids}) + command.validate() + + for role in command._properties["roles"]: + assert isinstance(role, CustomRoleModel) + + for owner in command._properties["owners"]: + assert isinstance(owner, CustomUserModel) diff --git a/tests/unit_tests/commands/databases/update_test.py b/tests/unit_tests/commands/databases/update_test.py index a74ff3c5c063c6833ef310e2c397fcb9d50e9f95..5e6b0869d72763416b86fc68f99f0ededdd5a53e 100644 --- a/tests/unit_tests/commands/databases/update_test.py +++ b/tests/unit_tests/commands/databases/update_test.py @@ -580,3 +580,65 @@ def test_update_other_fields_dont_affect_oauth( add_pvm.assert_not_called() database_needs_oauth2.purge_oauth2_tokens.assert_not_called() + + +def test_update_with_catalog_change(mocker: MockerFixture) -> None: + """ + Test that assets are updated when the main catalog changes. + """ + old_database = mocker.MagicMock(allow_multi_catalog=False) + old_database.get_default_catalog.return_value = "project-A" + old_database.id = 1 + + new_database = mocker.MagicMock(allow_multi_catalog=False) + new_database.get_default_catalog.return_value = "project-B" + + database_dao = mocker.patch("superset.commands.database.update.DatabaseDAO") + database_dao.find_by_id.return_value = old_database + database_dao.update.return_value = new_database + + mocker.patch("superset.commands.database.update.SyncPermissionsCommand") + mocker.patch.object( + UpdateDatabaseCommand, + "validate", + ) + update_catalog_attribute = mocker.patch.object( + UpdateDatabaseCommand, + "_update_catalog_attribute", + ) + + UpdateDatabaseCommand(1, {}).run() + + update_catalog_attribute.assert_called_once_with(1, "project-B") + + +def test_update_without_catalog_change(mocker: MockerFixture) -> None: + """ + Test that assets are not updated when the main catalog doesn't change. + """ + old_database = mocker.MagicMock(allow_multi_catalog=False) + old_database.database_name = "Ye Old DB" + old_database.get_default_catalog.return_value = "project-A" + old_database.id = 1 + + new_database = mocker.MagicMock(allow_multi_catalog=False) + new_database.database_name = "Fancy new DB" + new_database.get_default_catalog.return_value = "project-A" + + database_dao = mocker.patch("superset.commands.database.update.DatabaseDAO") + database_dao.find_by_id.return_value = old_database + database_dao.update.return_value = new_database + + mocker.patch("superset.commands.database.update.SyncPermissionsCommand") + mocker.patch.object( + UpdateDatabaseCommand, + "validate", + ) + update_catalog_attribute = mocker.patch.object( + UpdateDatabaseCommand, + "_update_catalog_attribute", + ) + + UpdateDatabaseCommand(1, {}).run() + + update_catalog_attribute.assert_not_called() diff --git a/tests/unit_tests/commands/export_test.py b/tests/unit_tests/commands/export_test.py index 2145aca17e06f0214c0007bd06a2534761932b60..426935c44b00809f3265ccfe75009e9961875cf9 100644 --- a/tests/unit_tests/commands/export_test.py +++ b/tests/unit_tests/commands/export_test.py @@ -16,9 +16,15 @@ # under the License. # pylint: disable=invalid-name, unused-argument, import-outside-toplevel +from unittest.mock import patch + +import pytest +import yaml from freezegun import freeze_time from pytest_mock import MockerFixture +from superset.extensions import feature_flag_manager + def test_export_assets_command(mocker: MockerFixture) -> None: """ @@ -80,7 +86,6 @@ def test_export_assets_command(mocker: MockerFixture) -> None: with freeze_time("2022-01-01T00:00:00Z"): command = ExportAssetsCommand() output = [(file[0], file[1]()) for file in list(command.run())] - assert output == [ ( "metadata.yaml", @@ -92,3 +97,61 @@ def test_export_assets_command(mocker: MockerFixture) -> None: ("dashboards/sales.yaml", "<DASHBOARD CONTENTS>"), ("queries/example/metric.yaml", "<SAVED QUERY CONTENTS>"), ] + + +@pytest.fixture +def mock_export_tags_command_charts_dashboards(mocker): + export_tags = mocker.patch("superset.commands.tag.export.ExportTagsCommand") + + def _mock_export(dashboard_ids=None, chart_ids=None): + if not feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"): + return iter([]) + return [ + ( + "tags.yaml", + lambda: yaml.dump( + { + "tags": [ + { + "tag_name": "tag_1", + "description": "Description for tag_1", + } + ] + }, + sort_keys=False, + ), + ), + ("charts/pie.yaml", lambda: "tag:\n- tag_1"), + ] + + export_tags.return_value._export.side_effect = _mock_export + return export_tags + + +def test_export_tags_with_charts_dashboards( + mock_export_tags_command_charts_dashboards, mocker +): + with patch.object(feature_flag_manager, "is_feature_enabled", return_value=True): + command = mock_export_tags_command_charts_dashboards() + result = list(command._export(chart_ids=[1])) + + file_name, file_content_func = result[0] + file_content = file_content_func() + assert file_name == "tags.yaml" + payload = yaml.safe_load(file_content) + assert payload["tags"] == [ + {"tag_name": "tag_1", "description": "Description for tag_1"} + ] + + file_name, file_content_func = result[1] + file_content = file_content_func() + assert file_name == "charts/pie.yaml" + assert file_content == "tag:\n- tag_1" + + with patch.object(feature_flag_manager, "is_feature_enabled", return_value=False): + command = mock_export_tags_command_charts_dashboards() + result = list(command._export(chart_ids=[1])) + assert not any(file_name == "tags.yaml" for file_name, _ in result) + assert all( + file_content_func() != "tag:\n- tag_1" for _, file_content_func in result + ) diff --git a/tests/unit_tests/dashboards/commands/importers/v1/import_test.py b/tests/unit_tests/dashboards/commands/importers/v1/import_test.py index a1e4a6f87e5baa65797a8307fd89764c1405bacf..1db4db33e19b6f5d828c3a751f754e9130711bd7 100644 --- a/tests/unit_tests/dashboards/commands/importers/v1/import_test.py +++ b/tests/unit_tests/dashboards/commands/importers/v1/import_test.py @@ -18,8 +18,10 @@ import copy from collections.abc import Generator +from unittest.mock import patch import pytest +import yaml from flask_appbuilder.security.sqla.models import Role, User from pytest_mock import MockerFixture from sqlalchemy.orm.session import Session @@ -27,7 +29,10 @@ from sqlalchemy.orm.session import Session from superset import security_manager from superset.commands.dashboard.importers.v1.utils import import_dashboard from superset.commands.exceptions import ImportFailedError +from superset.commands.importers.v1.utils import import_tag +from superset.extensions import feature_flag_manager from superset.models.dashboard import Dashboard +from superset.tags.models import TaggedObject from superset.utils.core import override_user from tests.integration_tests.fixtures.importexport import dashboard_config @@ -238,3 +243,43 @@ def test_import_existing_dashboard_with_permission( # Assert that the can write to dashboard was checked mock_can_access.assert_called_once_with("can_write", "Dashboard") mock_can_access_dashboard.assert_called_once_with(dashboard) + + +def test_import_tag_logic_for_dashboards(session_with_schema: Session): + contents = { + "tags.yaml": yaml.dump( + {"tags": [{"tag_name": "tag_1", "description": "Description for tag_1"}]} + ) + } + + object_id = 1 + object_type = "dashboards" + + with patch.object(feature_flag_manager, "is_feature_enabled", return_value=True): + new_tag_ids = import_tag( + ["tag_1"], contents, object_id, object_type, session_with_schema + ) + assert len(new_tag_ids) > 0 + assert ( + session_with_schema.query(TaggedObject) + .filter_by(object_id=object_id, object_type=object_type) + .count() + > 0 + ) + + session_with_schema.query(TaggedObject).filter_by( + object_id=object_id, object_type=object_type + ).delete() + session_with_schema.commit() + + with patch.object(feature_flag_manager, "is_feature_enabled", return_value=False): + new_tag_ids_disabled = import_tag( + ["tag_1"], contents, object_id, object_type, session_with_schema + ) + assert len(new_tag_ids_disabled) == 0 + associated_tags = ( + session_with_schema.query(TaggedObject) + .filter_by(object_id=object_id, object_type=object_type) + .all() + ) + assert len(associated_tags) == 0 diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py b/tests/unit_tests/db_engine_specs/test_databricks.py index 95403780e11b4a494af34a03e541c0b83f1ffabf..a58500200977cea2a73512d06cab4bbcb0d68c6d 100644 --- a/tests/unit_tests/db_engine_specs/test_databricks.py +++ b/tests/unit_tests/db_engine_specs/test_databricks.py @@ -187,7 +187,7 @@ def test_extract_errors() -> None: error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR, level=ErrorLevel.ERROR, extra={ - "engine_name": "Databricks", + "engine_name": "Databricks (legacy)", "issue_codes": [ { "code": 1002, @@ -214,7 +214,7 @@ def test_extract_errors_with_context() -> None: error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR, level=ErrorLevel.ERROR, extra={ - "engine_name": "Databricks", + "engine_name": "Databricks (legacy)", "issue_codes": [ { "code": 1002, diff --git a/tests/unit_tests/jinja_context_test.py b/tests/unit_tests/jinja_context_test.py index be6e5fb55e3a3057181a183f047a3cf7e81bf410..fa79cc04936303df6e2edb75e160340730f1d83d 100644 --- a/tests/unit_tests/jinja_context_test.py +++ b/tests/unit_tests/jinja_context_test.py @@ -17,9 +17,11 @@ # pylint: disable=invalid-name, unused-argument from __future__ import annotations +from datetime import datetime from typing import Any import pytest +from flask_appbuilder.security.sqla.models import Role from freezegun import freeze_time from jinja2 import DebugUndefined from jinja2.sandbox import SandboxedEnvironment @@ -38,6 +40,7 @@ from superset.jinja_context import ( metric_macro, safe_proxy, TimeFilter, + to_datetime, WhereInMacro, ) from superset.models.core import Database @@ -358,19 +361,26 @@ def test_user_macros(mocker: MockerFixture): - ``current_user_id`` - ``current_username`` - ``current_user_email`` + - ``current_user_roles`` """ mock_g = mocker.patch("superset.utils.core.g") + mock_get_user_roles = mocker.patch("superset.security_manager.get_user_roles") mock_cache_key_wrapper = mocker.patch( "superset.jinja_context.ExtraCache.cache_key_wrapper" ) mock_g.user.id = 1 mock_g.user.username = "my_username" mock_g.user.email = "my_email@test.com" + mock_get_user_roles.return_value = [Role(name="my_role1"), Role(name="my_role2")] cache = ExtraCache() assert cache.current_user_id() == 1 assert cache.current_username() == "my_username" assert cache.current_user_email() == "my_email@test.com" - assert mock_cache_key_wrapper.call_count == 3 + assert cache.current_user_roles() == ["my_role1", "my_role2"] + assert mock_cache_key_wrapper.call_count == 4 + + mock_get_user_roles.return_value = [] + assert cache.current_user_roles() is None def test_user_macros_without_cache_key_inclusion(mocker: MockerFixture): @@ -378,16 +388,19 @@ def test_user_macros_without_cache_key_inclusion(mocker: MockerFixture): Test all user macros with ``add_to_cache_keys`` set to ``False``. """ mock_g = mocker.patch("superset.utils.core.g") + mock_get_user_roles = mocker.patch("superset.security_manager.get_user_roles") mock_cache_key_wrapper = mocker.patch( "superset.jinja_context.ExtraCache.cache_key_wrapper" ) mock_g.user.id = 1 mock_g.user.username = "my_username" mock_g.user.email = "my_email@test.com" + mock_get_user_roles.return_value = [Role(name="my_role1"), Role(name="my_role2")] cache = ExtraCache() assert cache.current_user_id(False) == 1 assert cache.current_username(False) == "my_username" assert cache.current_user_email(False) == "my_email@test.com" + assert cache.current_user_roles(False) == ["my_role1", "my_role2"] assert mock_cache_key_wrapper.call_count == 0 @@ -401,6 +414,7 @@ def test_user_macros_without_user_info(mocker: MockerFixture): assert cache.current_user_id() == None # noqa: E711 assert cache.current_username() == None # noqa: E711 assert cache.current_user_email() == None # noqa: E711 + assert cache.current_user_roles() == None # noqa: E711 def test_where_in() -> None: @@ -429,6 +443,59 @@ def test_where_in_empty_list() -> None: assert where_in([], default_to_none=True) is None +@pytest.mark.parametrize( + "value,format,output", + [ + ("2025-03-20 15:55:00", None, datetime(2025, 3, 20, 15, 55)), + (None, None, None), + ("2025-03-20", "%Y-%m-%d", datetime(2025, 3, 20)), + ("'2025-03-20'", "%Y-%m-%d", datetime(2025, 3, 20)), + ], +) +def test_to_datetime( + value: str | None, format: str | None, output: datetime | None +) -> None: + """ + Test the ``to_datetime`` custom filter. + """ + + result = ( + to_datetime(value, format=format) if format is not None else to_datetime(value) + ) + assert result == output + + +@pytest.mark.parametrize( + "value,format,match", + [ + ( + "2025-03-20", + None, + "time data '2025-03-20' does not match format '%Y-%m-%d %H:%M:%S'", + ), + ( + "2025-03-20 15:55:00", + "%Y-%m-%d", + "unconverted data remains: 15:55:00", + ), + ], +) +def test_to_datetime_raises(value: str, format: str | None, match: str) -> None: + """ + Test the ``to_datetime`` custom filter raises with an incorrect + format. + """ + with pytest.raises( + ValueError, + match=match, + ): + ( + to_datetime(value, format=format) + if format is not None + else to_datetime(value) + ) + + def test_dataset_macro(mocker: MockerFixture) -> None: """ Test the ``dataset_macro`` macro. diff --git a/tests/unit_tests/models/helpers_test.py b/tests/unit_tests/models/helpers_test.py index cc106dbe649b2060d2c5faecc9dc498a68be6dc5..4e8aa493be3b646831b4bf3266091df324f6c96d 100644 --- a/tests/unit_tests/models/helpers_test.py +++ b/tests/unit_tests/models/helpers_test.py @@ -25,7 +25,7 @@ from unittest.mock import patch import pytest from pytest_mock import MockerFixture -from sqlalchemy import create_engine +from sqlalchemy import create_engine, text from sqlalchemy.orm.session import Session from sqlalchemy.pool import StaticPool @@ -201,10 +201,12 @@ def test_values_for_column_double_percents( ) # make sure final query has single percents with database.get_sqla_engine() as engine: - pd.read_sql_query.assert_called_with( - sql=( - "SELECT DISTINCT CASE WHEN b LIKE 'A%' THEN 'yes' ELSE 'nope' END " - "AS column_values \nFROM t\n LIMIT 10000 OFFSET 0" - ), - con=engine, + expected_sql = text( + "SELECT DISTINCT CASE WHEN b LIKE 'A%' THEN 'yes' ELSE 'nope' END " + "AS column_values \nFROM t\n LIMIT 10000 OFFSET 0" ) + called_sql = pd.read_sql_query.call_args.kwargs["sql"] + called_conn = pd.read_sql_query.call_args.kwargs["con"] + + assert called_sql.compare(expected_sql) is True + assert called_conn == engine diff --git a/tests/unit_tests/security/api_test.py b/tests/unit_tests/security/api_test.py index 73227166c212dc807d6c49877b8bad6ab94c68c1..faeec96f558769a71ecd9c453837fa1e0b32907f 100644 --- a/tests/unit_tests/security/api_test.py +++ b/tests/unit_tests/security/api_test.py @@ -32,4 +32,9 @@ def test_csrf_not_exempt(app_context: None) -> None: "MenuApi", "SecurityApi", "OpenApi", + "PermissionViewMenuApi", + "SupersetRoleApi", + "UserApi", + "PermissionApi", + "ViewMenuApi", } diff --git a/tests/unit_tests/utils/excel_tests.py b/tests/unit_tests/utils/excel_tests.py index deb6d3d0b4eafb17edead945c80065497e7c4bfb..b07fe7a4f0dc1790a585c9f184ed7b733c56a250 100644 --- a/tests/unit_tests/utils/excel_tests.py +++ b/tests/unit_tests/utils/excel_tests.py @@ -105,3 +105,27 @@ def test_column_data_types_with_failing_conversion(): assert not is_numeric_dtype(df["col1"]) assert not is_numeric_dtype(df["col2"]) assert not is_numeric_dtype(df["col3"]) + + +def test_column_data_types_with_large_numeric_values(): + df = pd.DataFrame( + { + "big_number": [ + 10**14, + 999999999999999, + 10**15 + 1, + 10**16, + 1100108628127863, + 2**54, + ], + } + ) + apply_column_types(df, [GenericDataType.NUMERIC]) + assert df["big_number"].tolist() == [ + 100000000000000, + 999999999999999, + "1000000000000001", + "10000000000000000", + "1100108628127863", + "18014398509481984", + ] diff --git a/tests/unit_tests/utils/slack_test.py b/tests/unit_tests/utils/slack_test.py index ed7a82c220c76c4547a4c3401dee4a679076c899..024d6cf96ee0d64e30647ebc2a64e7de27fd4b47 100644 --- a/tests/unit_tests/utils/slack_test.py +++ b/tests/unit_tests/utils/slack_test.py @@ -17,7 +17,7 @@ import pytest -from superset.utils.slack import get_channels_with_search +from superset.utils.slack import get_channels_with_search, SlackChannelTypes class MockResponse: @@ -150,15 +150,35 @@ class TestGetChannelsWithSearch: The server responded with: missing scope: channels:read""" ) - def test_filter_channels_by_specified_types(self, mocker): + @pytest.mark.parametrize( + "types, expected_channel_ids", + [ + ([SlackChannelTypes.PUBLIC], {"public_channel_id"}), + ([SlackChannelTypes.PRIVATE], {"private_channel_id"}), + ( + [SlackChannelTypes.PUBLIC, SlackChannelTypes.PRIVATE], + {"public_channel_id", "private_channel_id"}, + ), + ([], {"public_channel_id", "private_channel_id"}), + ], + ) + def test_filter_channels_by_specified_types( + self, types: list[SlackChannelTypes], expected_channel_ids: set[str], mocker + ): mock_data = { "channels": [ { - "id": "C12345", - "name": "general", + "id": "public_channel_id", + "name": "open", "is_member": False, "is_private": False, }, + { + "id": "private_channel_id", + "name": "secret", + "is_member": False, + "is_private": True, + }, ], "response_metadata": {"next_cursor": None}, } @@ -168,15 +188,8 @@ The server responded with: missing scope: channels:read""" mock_client.conversations_list.return_value = mock_response_instance mocker.patch("superset.utils.slack.get_slack_client", return_value=mock_client) - result = get_channels_with_search(types=["public"]) - assert result == [ - { - "id": "C12345", - "name": "general", - "is_member": False, - "is_private": False, - } - ] + result = get_channels_with_search(types=types) + assert {channel["id"] for channel in result} == expected_channel_ids def test_handle_pagination_multiple_pages(self, mocker): mock_data_page1 = {