diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 82efcf6558410..e2f6a79affc53 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,14 +1,19 @@ # Notify all committers of DB migration changes, per SIP-59 + # https://github.com/apache/superset/issues/13351 + /superset/migrations/ @apache/superset-committers # Notify Preset team when ephemeral env settings are changed + .github/workflows/ecs-task-definition.json @robdiciuccio @craig-rueda @rusackas @eschutho @dpgaspar @nytai @mistercrunch .github/workflows/docker-ephemeral-env.yml @robdiciuccio @craig-rueda @rusackas @eschutho @dpgaspar @nytai @mistercrunch .github/workflows/ephemeral*.yml @robdiciuccio @craig-rueda @rusackas @eschutho @dpgaspar @nytai @mistercrunch # Notify some committers of changes in the Select component -/superset-frontend/src/components/Select/ @michael-s-molina @geido + +/superset-frontend/src/components/Select/ @michael-s-molina @geido @ktmud # Notify Helm Chart maintainers about changes in it + /helm/superset/ @craig-rueda @dpgaspar @villebro diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index cb66edb2bcc76..8e6e0da9c9597 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -5,14 +5,10 @@ labels: "#enhancement" --- -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] +Github Discussions is our new home for discussing features and improvements! -**Describe the solution you'd like** -A clear and concise description of what you want to happen. +https://github.com/apache/superset/discussions/categories/ideas -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. +We'd like to keep Github Issues focuses on bugs and SIP's (Superset Improvement Proposals)! -**Additional context** -Add any other context or screenshots about the feature request here. +Please note that feature requests opened as Github Issues will be moved to Discussions. diff --git a/.github/ISSUE_TEMPLATE/security_vulnerability.md b/.github/ISSUE_TEMPLATE/security_vulnerability.md deleted file mode 100644 index 9cdad9b4bd7da..0000000000000 --- a/.github/ISSUE_TEMPLATE/security_vulnerability.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: Security vulnerability -about: Report a security vulnerability or issue -labels: "#security" - ---- - -## DO NOT REPORT SECURITY VULNERABILITIES HERE - -Please report security vulnerabilities to private@superset.apache.org. - -In the event a community member discovers a security flaw in Superset, it is important to follow the [Apache Security Guidelines](https://www.apache.org/security/committers.html) and release a fix as quickly as possible before public disclosure. Reporting security vulnerabilities through the usual GitHub Issues channel is not ideal as it will publicize the flaw before a fix can be applied. diff --git a/.github/workflows/superset-python-unittest.yml b/.github/workflows/superset-python-unittest.yml index 738c6138574b5..64db4d3e649af 100644 --- a/.github/workflows/superset-python-unittest.yml +++ b/.github/workflows/superset-python-unittest.yml @@ -51,7 +51,7 @@ jobs: - name: Python unit tests if: steps.check.outcome == 'failure' run: | - pytest --durations=0 ./tests/common ./tests/unit_tests --cache-clear + pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear - name: Upload code coverage if: steps.check.outcome == 'failure' run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2429a0153f009..b43e10c2cb784 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,3 +51,9 @@ repos: - id: prettier args: ['--ignore-path=./superset-frontend/.prettierignore'] files: 'superset-frontend' + # blacklist unsafe functions like make_url (see #19526) + - repo: https://github.com/skorokithakis/blacklist-pre-commit-hook + rev: e2f070289d8eddcaec0b580d3bde29437e7c8221 + hooks: + - id: blacklist + args: ["--blacklisted-names=make_url", "--ignore=tests/"] diff --git a/README.md b/README.md index 9d95180f26370..5bea6eed78ba0 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Superset provides: **Large Gallery of Visualizations** -
+
**Craft Beautiful, Dynamic Dashboards** diff --git a/RELEASING/Dockerfile.from_local_tarball b/RELEASING/Dockerfile.from_local_tarball index be08d6fb951fc..3cd030609b60e 100644 --- a/RELEASING/Dockerfile.from_local_tarball +++ b/RELEASING/Dockerfile.from_local_tarball @@ -34,7 +34,7 @@ RUN apt-get install -y build-essential libssl-dev \ # Install nodejs for custom build # https://nodejs.org/en/download/package-manager/ -RUN curl -sL https://deb.nodesource.com/setup_12.x | bash - \ +RUN curl -sL https://deb.nodesource.com/setup_16.x | bash - \ && apt-get install -y nodejs RUN mkdir -p /home/superset diff --git a/RELEASING/Dockerfile.from_svn_tarball b/RELEASING/Dockerfile.from_svn_tarball index 5f9bde9c3f9ba..482ab474a58e3 100644 --- a/RELEASING/Dockerfile.from_svn_tarball +++ b/RELEASING/Dockerfile.from_svn_tarball @@ -34,7 +34,7 @@ RUN apt-get install -y build-essential libssl-dev \ # Install nodejs for custom build # https://nodejs.org/en/download/package-manager/ -RUN curl -sL https://deb.nodesource.com/setup_12.x | bash - \ +RUN curl -sL https://deb.nodesource.com/setup_16.x | bash - \ && apt-get install -y nodejs RUN mkdir -p /home/superset diff --git a/RELEASING/README.md b/RELEASING/README.md index 8724cd1642c89..32fb1aef34cab 100644 --- a/RELEASING/README.md +++ b/RELEASING/README.md @@ -30,6 +30,7 @@ partaking in the process should join the channel. ## Release notes for recent releases +- [1.5](release-notes-1-5/README.md) - [1.4](release-notes-1-4/README.md) - [1.3](release-notes-1-3/README.md) - [1.2](release-notes-1-2/README.md) diff --git a/RELEASING/release-notes-1-5/README.md b/RELEASING/release-notes-1-5/README.md new file mode 100644 index 0000000000000..7444fa4d7c8e6 --- /dev/null +++ b/RELEASING/release-notes-1-5/README.md @@ -0,0 +1,142 @@ + + +# Release Notes for Superset 1.5 + +Superset 1.5 focuses on polishing the dashboard native filters experience, while +improving performance and stability. Superset 1.5 is likely the last minor release of +version 1 of Superset, and will be succeeded by Superset 2.0. The 1.5 branch +introduces the notion of a Long Term Support (LTS) version of Superset, and will +receive security and other critical fixes even after Superset 2.x is released. +Therefore, users will have the choice of staying on the 1.5 branch or upgrading to 2.x +when available. + +- [**User Experience**](#user-facing-features) +- [**Feature flags**](#feature-flags) +- [**Database Experience**](#database-experience) +- [**Breaking Changes and Full Changelog**](#breaking-changes-and-full-changelog) + +## User Facing Features + +- Complex dashboards with lots of native filters and charts will render considerably + faster. See the videos that shows the rendering time of a complex dashboard go from + 11 to 3 seconds: [#19064](https://github.com/apache/superset/pull/19064). In + addition, applying filters and switching tabs is also much smoother. +- The Native Filter Bar has been redesigned, along with moving the "Apply" and + "Clear all" buttons to the bottom: + +![Filter bar](media/filter_bar.png) + +- Native filters can now be made dependent on multiple filters. This makes it possible + to restrict the available values in a filter based on the selection of other filters. + +![Dependent filters](media/dependent_filters.png) + +- In addition to being able to write Custom SQL for adhoc metrics and filters, the + column control now also features a Custom SQL tab. This makes it possible to write + custom expressions directly in charts without adding them to the dataset as saved + expressions. + +![Adhoc columns](media/adhoc_columns.png) + +- A new `SupersetMetastoreCache` has been added which makes it possible to cache data + in the Superset Metastore without the need for running a dedicated cache like Redis + or Memcached. The new cache will be used by default for required caches, but can also + be used for caching chart or other data. See the + [documentation](https://superset.apache.org/docs/installation/cache#caching) for + details on using the new cache. +- Previously it was possible for Dashboards with lots of filters to cause an error. + A similar issue existed on Explore. Now Superset stores Dashboard and Explore state + in the cache (as opposed to the URL), eliminating the infamous + [Long URL Problem](https://github.com/apache/superset/issues/17086). +- Previously permanent links to Dashboard and Explore pages were in fact shortened URLS + that relied on state being stored in the URL (see Long URL Problem above). In + addition, the links used numerical ids and didn't check user permissions making it + easy to iterate through links that were stored in the metastore. Now permanent links + state is stored as JSON objects in the metastore, making it possible to store + arbitrarily large Dashboard and Explore state in permalinks. In addition, the ids + are encoded using [`hashids`](https://hashids.org/) and check permissions, making + permalink state more secure. + +![Dashboard permalink](media/permalink.png) + +## Feature flags + +- A new feature flag `GENERIC_CHART_AXES` has been added that makes it possible to + use a non-temporal x-axis on the ECharts Timeseries chart + ([#17917](https://github.com/apache/superset/pull/17917)). When enabled, a new + control "X Axis" is added to the control panel of ECharts line, area, bar, step and + scatter charts, which makes it possible to use categorical or numerical x-axes on + those charts. + +![Categorical line chart](media/categorical_line.png) + +## Database Experience + +- DuckDB: Add support for database: + [#19317](https://github.com/apache/superset/pull/19317) + +- Kusto: Add support for Azure Data Explorer (Kusto): + [#17898](https://github.com/apache/superset/pull/17898) + +- Trino: Add server cert support and new auth methods: + [#17593](https://github.com/apache/superset/pull/17593) and + [#16346](https://github.com/apache/superset/pull/16346) + +- Microsoft SQL Server (MSSQL): support using CTEs in virtual tables: + [#18567](https://github.com/apache/superset/pull/18567) + +- Teradata and MSSQL: add support for TOP limit syntax: + [#18746](https://github.com/apache/superset/pull/18746) and + [#18240](https://github.com/apache/superset/pull/18240) + +- Apache Drill: User impersonation using `drill+sadrill`: + [#19252](https://github.com/apache/superset/pull/19252) + +## Developer Experience + +- `superset-ui` has now been integrated into the Superset codebase as per + [SIP-58](https://github.com/apache/superset/issues/13013) dubbed "Monorepo". This + makes development of plugins that ship with Superset considerably simpler. In + addition, it makes it possible to align `superset-ui` releases with official Superset + releases. + +## Breaking Changes and Full Changelog + +**Breaking Changes** + +- Bump `mysqlclient` from v1 to v2: + [#17556](https://github.com/apache/superset/pull/17556) +- Single and double quotes will no longer be removed from filter values: + [#17881](https://github.com/apache/superset/pull/17881) +- Previously `QUERY_COST_FORMATTERS_BY_ENGINE`, `SQL_VALIDATORS_BY_ENGINE` and + `SCHEDULED_QUERIES` were expected to be defined in the feature flag dictionary in + the `config.py` file. These should now be defined as a top-level config, with the + feature flag dictionary being reserved for boolean only values: + [#15254](https://github.com/apache/superset/pull/15254) +- All Superset CLI commands (init, load_examples and etc) require setting the + `FLASK_APP` environment variable (which is set by default when `.flaskenv` is loaded): + [#17539](https://github.com/apache/superset/pull/17539) + +**Changelog** + +To see the complete changelog in this release, head to +[CHANGELOG.MD](https://github.com/apache/superset/blob/1.5/CHANGELOG.md). +As mentioned earlier, this release has a MASSIVE amount of bug fixes. The full +changelog lists all of them! diff --git a/RELEASING/release-notes-1-5/media/adhoc_columns.png b/RELEASING/release-notes-1-5/media/adhoc_columns.png new file mode 100644 index 0000000000000..6c73693625a5c Binary files /dev/null and b/RELEASING/release-notes-1-5/media/adhoc_columns.png differ diff --git a/RELEASING/release-notes-1-5/media/categorical_line.png b/RELEASING/release-notes-1-5/media/categorical_line.png new file mode 100644 index 0000000000000..8d88aee15bca7 Binary files /dev/null and b/RELEASING/release-notes-1-5/media/categorical_line.png differ diff --git a/RELEASING/release-notes-1-5/media/dependent_filters.png b/RELEASING/release-notes-1-5/media/dependent_filters.png new file mode 100644 index 0000000000000..a92afcf32c0f9 Binary files /dev/null and b/RELEASING/release-notes-1-5/media/dependent_filters.png differ diff --git a/RELEASING/release-notes-1-5/media/filter_bar.png b/RELEASING/release-notes-1-5/media/filter_bar.png new file mode 100644 index 0000000000000..61170fea6d60e Binary files /dev/null and b/RELEASING/release-notes-1-5/media/filter_bar.png differ diff --git a/RELEASING/release-notes-1-5/media/permalink.png b/RELEASING/release-notes-1-5/media/permalink.png new file mode 100644 index 0000000000000..8edebd4c4553c Binary files /dev/null and b/RELEASING/release-notes-1-5/media/permalink.png differ diff --git a/UPDATING.md b/UPDATING.md index 47bf234692d40..fb6565848a164 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -26,6 +26,7 @@ assists people when migrating to a new version. - [19046](https://github.com/apache/superset/pull/19046): Enables the drag and drop interface in Explore control panel by default. Flips `ENABLE_EXPLORE_DRAG_AND_DROP` and `ENABLE_DND_WITH_CLICK_UX` feature flags to `True`. - [18936](https://github.com/apache/superset/pull/18936): Removes legacy SIP-15 interm logic/flags—specifically the `SIP_15_ENABLED`, `SIP_15_GRACE_PERIOD_END`, `SIP_15_DEFAULT_TIME_RANGE_ENDPOINTS`, and `SIP_15_TOAST_MESSAGE` flags. Time range endpoints are no longer configurable and strictly adhere to the `[start, end)` paradigm, i.e., inclusive of the start and exclusive of the end. Additionally this change removes the now obsolete `time_range_endpoints` from the form-data and resulting in the cache being busted. +- [19570](https://github.com/apache/superset/pull/19570): makes [sqloxide](https://pypi.org/project/sqloxide/) optional so the SIP-68 migration can be run on aarch64. If the migration is taking too long installing sqloxide manually should improve the performance. ### Breaking Changes diff --git a/docker/docker-init.sh b/docker/docker-init.sh index 07830694048a7..c98f49881ada7 100755 --- a/docker/docker-init.sh +++ b/docker/docker-init.sh @@ -41,7 +41,7 @@ ADMIN_PASSWORD="admin" # If Cypress run – overwrite the password for admin and export env variables if [ "$CYPRESS_CONFIG" == "true" ]; then ADMIN_PASSWORD="general" - export SUPERSET_CONFIG=tests.superset_test_config + export SUPERSET_CONFIG=tests.integration_tests.superset_test_config export SUPERSET_TESTENV=true export SUPERSET__SQLALCHEMY_DATABASE_URI=postgresql+psycopg2://superset:superset@db:5432/superset fi diff --git a/docs/docs/installation/installing-superset-from-scratch.mdx b/docs/docs/installation/installing-superset-from-scratch.mdx index d64db45a84c2c..195f9cfd11e2b 100644 --- a/docs/docs/installation/installing-superset-from-scratch.mdx +++ b/docs/docs/installation/installing-superset-from-scratch.mdx @@ -18,13 +18,13 @@ level dependencies. The following command will ensure that the required dependencies are installed: ``` -sudo apt-get install build-essential libssl-dev libffi-dev python-dev python-pip libsasl2-dev libldap2-dev +sudo apt-get install build-essential libssl-dev libffi-dev python-dev python-pip libsasl2-dev libldap2-dev default-libmysqlclient-dev ``` In Ubuntu 20.04 the following command will ensure that the required dependencies are installed: ``` -sudo apt-get install build-essential libssl-dev libffi-dev python3-dev python3-pip libsasl2-dev libldap2-dev +sudo apt-get install build-essential libssl-dev libffi-dev python3-dev python3-pip libsasl2-dev libldap2-dev default-libmysqlclient-dev ``` **Fedora and RHEL-derivative Linux distributions** diff --git a/docs/docs/installation/sql-templating.mdx b/docs/docs/installation/sql-templating.mdx index 40f0744fba6d0..2a80f0fbf65f6 100644 --- a/docs/docs/installation/sql-templating.mdx +++ b/docs/docs/installation/sql-templating.mdx @@ -33,7 +33,7 @@ For example, to add a time range to a virtual dataset, you can write the followi SELECT * from tbl where dttm_col > '{{ from_dttm }}' and dttm_col < '{{ to_dttm }}' ``` -To add custom functionality to the Jinja context, you need to to to overload the default Jinja +To add custom functionality to the Jinja context, you need to overload the default Jinja context in your environment by defining the `JINJA_CONTEXT_ADDONS` in your superset configuration (`superset_config.py`). Objects referenced in this dictionary are made available for users to use where the Jinja context is made available. @@ -206,10 +206,12 @@ Here's a concrete example: SELECT action, count(*) as times FROM logs WHERE - action in ({{ "'" + "','".join(filter_values('action_type')) + "'" }}) + action in {{ filter_values('action_type')|where_in }} GROUP BY action ``` +There `where_in` filter converts the list of values from `filter_values('action_type')` into a string suitable for an `IN` expression. + **Filters for a Specific Column** The `{{ get_filters() }}` macro returns the filters applied to a given column. In addition to @@ -243,7 +245,7 @@ Here's a concrete example: {%- if filter.get('op') == 'IN' -%} AND - full_name IN ( {{ "'" + "', '".join(filter.get('val')) + "'" }} ) + full_name IN {{ filter.get('val')|where_in }} {%- endif -%} {%- if filter.get('op') == 'LIKE' -%} diff --git a/docs/yarn.lock b/docs/yarn.lock index 5acd9afea07f4..f58b8a5078d35 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -7429,9 +7429,9 @@ minimatch@^3.0.4: brace-expansion "^1.1.7" minimist@^1.2.0, minimist@^1.2.5: - version "1.2.5" - resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== mkdirp@^0.5.5, mkdirp@~0.5.1: version "0.5.5" @@ -7441,9 +7441,9 @@ mkdirp@^0.5.5, mkdirp@~0.5.1: minimist "^1.2.5" moment@^2.24.0, moment@^2.25.3: - version "2.29.1" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" - integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== + version "2.29.2" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.2.tgz#00910c60b20843bcba52d37d58c628b47b1f20e4" + integrity sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg== ms@2.0.0: version "2.0.0" @@ -7529,9 +7529,9 @@ node-fetch@2.6.1: integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== node-forge@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.2.1.tgz#82794919071ef2eb5c509293325cec8afd0fd53c" - integrity sha512-Fcvtbb+zBcZXbTTVwqGA5W+MKBj56UjVRevvchv5XrcyXbmNdesfZL37nlcWOfpgHhgmxApw3tQbTr4CqNmX4w== + version "1.3.1" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" + integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== node-releases@^2.0.1: version "2.0.1" diff --git a/requirements/development.in b/requirements/development.in index 163bc5082ab6b..a9aa89a2c269f 100644 --- a/requirements/development.in +++ b/requirements/development.in @@ -18,7 +18,7 @@ -r base.in flask-cors>=2.0.0 mysqlclient>=2.1.0 -pillow>=8.3.2,<10 +pillow>=9.0.1,<10 pydruid>=0.6.1,<0.7 pyhive[hive]>=0.6.1 psycopg2-binary==2.9.1 diff --git a/requirements/development.txt b/requirements/development.txt index 8012be69b428d..42302765b2050 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -1,4 +1,4 @@ -# SHA1:cb189e676baa2e397294abb48eaefba5bf408522 +# SHA1:b4a3e0dd12a4937fc5a21bdbf63644be9222c65f # # This file is autogenerated by pip-compile-multi # To update, run: @@ -40,7 +40,7 @@ mysqlclient==2.1.0 # via -r requirements/development.in openpyxl==3.0.7 # via tabulator -pillow==9.0.0 +pillow==9.1.0 # via -r requirements/development.in progress==1.6 # via -r requirements/development.in diff --git a/requirements/testing.in b/requirements/testing.in index c33f245280bb0..082dbc934ac5c 100644 --- a/requirements/testing.in +++ b/requirements/testing.in @@ -36,6 +36,7 @@ pytest pytest-cov statsd pytest-mock +sqloxide # DB dependencies -e file:.[bigquery] -e file:.[trino] diff --git a/requirements/testing.txt b/requirements/testing.txt index 3b1ce021873f5..dbdb9e5077e87 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -1,4 +1,4 @@ -# SHA1:7a8e256097b4758bdeda2529d3d4d31e421e1a3c +# SHA1:e273e8da6bfd5f6f8563fe067e243297cc7c588c # # This file is autogenerated by pip-compile-multi # To update, run: @@ -52,7 +52,6 @@ google-auth-oauthlib==0.4.6 google-cloud-bigquery[bqstorage,pandas]==2.29.0 # via # -r requirements/testing.in - # apache-superset # pandas-gbq # pybigquery google-cloud-bigquery-storage==2.9.1 @@ -105,9 +104,7 @@ openapi-schema-validator==0.1.5 openapi-spec-validator==0.3.1 # via -r requirements/testing.in pandas-gbq==0.15.0 - # via - # -r requirements/testing.in - # apache-superset + # via -r requirements/testing.in parameterized==0.8.1 # via -r requirements/testing.in parso==0.8.2 @@ -136,9 +133,7 @@ pyasn1==0.4.8 pyasn1-modules==0.2.8 # via google-auth pybigquery==0.10.2 - # via - # -r requirements/testing.in - # apache-superset + # via -r requirements/testing.in pydata-google-auth==1.2.0 # via pandas-gbq pyfakefs==4.5.0 @@ -166,6 +161,8 @@ rsa==4.7.2 # via google-auth sqlalchemy-trino==0.4.1 # via apache-superset +sqloxide==0.1.15 + # via -r requirements/testing.in statsd==3.3.0 # via -r requirements/testing.in traitlets==5.0.5 diff --git a/scripts/python_tests.sh b/scripts/python_tests.sh index 36b2b808025c4..0554f8ca65ad9 100755 --- a/scripts/python_tests.sh +++ b/scripts/python_tests.sh @@ -32,4 +32,4 @@ superset init echo "Running tests" -pytest --durations=0 --maxfail=1 --cov=superset "$@" +pytest --durations-min=2 --maxfail=1 --cov-report= --cov=superset "$@" diff --git a/setup.py b/setup.py index c7cdd2acd2185..d7544ca3a3d99 100644 --- a/setup.py +++ b/setup.py @@ -111,7 +111,6 @@ def get_git_sha() -> str: "slackclient==2.5.0", # PINNED! slack changes file upload api in the future versions "sqlalchemy>=1.3.16, <1.4, !=1.3.21", "sqlalchemy-utils>=0.37.8, <0.38", - "sqloxide==0.1.15", "sqlparse==0.3.0", # PINNED! see https://github.com/andialbrecht/sqlparse/issues/562 "tabulate==0.8.9", # needed to support Literal (3.8) and TypeGuard (3.10) @@ -141,7 +140,7 @@ def get_git_sha() -> str: "excel": ["xlrd>=1.2.0, <1.3"], "firebird": ["sqlalchemy-firebird>=0.7.0, <0.8"], "firebolt": ["firebolt-sqlalchemy>=0.0.1"], - "gsheets": ["shillelagh[gsheetsapi]>=1.0.3, <2"], + "gsheets": ["shillelagh[gsheetsapi]>=1.0.11, <2"], "hana": ["hdbcli==2.4.162", "sqlalchemy_hana==0.4.0"], "hive": ["pyhive[hive]>=0.6.1", "tableschema", "thrift>=0.11.0, <1.0.0"], "impala": ["impyla>0.16.2, <0.17"], @@ -164,7 +163,7 @@ def get_git_sha() -> str: "snowflake-sqlalchemy==1.2.4" ], # PINNED! 1.2.5 introduced breaking changes requiring sqlalchemy>=1.4.0 "teradata": ["teradatasql>=16.20.0.23"], - "thumbnails": ["Pillow>=8.3.2, <10.0.0"], + "thumbnails": ["Pillow>=9.0.1, <10.0.0"], "vertica": ["sqlalchemy-vertica-python>=0.5.9, < 0.6"], "netezza": ["nzalchemy>=11.0.2"], }, diff --git a/superset-embedded-sdk/package-lock.json b/superset-embedded-sdk/package-lock.json index cd52282c71c47..55c2474f25c0f 100644 --- a/superset-embedded-sdk/package-lock.json +++ b/superset-embedded-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@superset-ui/embedded-sdk", - "version": "0.1.0-alpha.6", + "version": "0.1.0-alpha.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@superset-ui/embedded-sdk", - "version": "0.1.0-alpha.6", + "version": "0.1.0-alpha.7", "license": "Apache-2.0", "dependencies": { "@superset-ui/switchboard": "^0.18.26-0" @@ -6647,9 +6647,9 @@ } }, "node_modules/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "node_modules/ms": { @@ -13117,9 +13117,9 @@ } }, "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "ms": { diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.helper.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.helper.ts index 1458fc7d5982d..9822f7035952d 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.helper.ts +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.helper.ts @@ -1,4 +1,9 @@ import { getChartAlias, Slice } from 'cypress/utils/vizPlugins'; +import { + dashboardView, + editDashboardView, + nativeFilters, +} from 'cypress/support/directories'; /** * Licensed to the Apache Software Foundation (ASF) under one @@ -25,7 +30,13 @@ export const testItems = { dashboard: 'Cypress Sales Dashboard', dataset: 'Vehicle Sales', chart: 'Cypress chart', + newChart: 'New Cypress Chart', + createdDashboard: 'New Dashboard', defaultNameDashboard: '[ untitled dashboard ]', + newDashboardTitle: `Test dashboard [NEW TEST]`, + bulkFirstNameDashboard: 'First Dash', + bulkSecondNameDashboard: 'Second Dash', + worldBanksDataCopy: `World Bank's Data [copy]`, }; export const CHECK_DASHBOARD_FAVORITE_ENDPOINT = @@ -133,3 +144,112 @@ export function resize(selector: string) { }, }; } + +export function cleanUp() { + cy.deleteDashboardByName(testItems.dashboard); + cy.deleteDashboardByName(testItems.defaultNameDashboard); + cy.deleteDashboardByName(''); + cy.deleteDashboardByName(testItems.newDashboardTitle); + cy.deleteDashboardByName(testItems.bulkFirstNameDashboard); + cy.deleteDashboardByName(testItems.bulkSecondNameDashboard); + cy.deleteDashboardByName(testItems.createdDashboard); + cy.deleteDashboardByName(testItems.worldBanksDataCopy); + cy.deleteChartByName(testItems.chart); + cy.deleteChartByName(testItems.newChart); +} + +/** ************************************************************************ + * Clicks on new filter button + * @returns {None} + * @summary helper for adding new filter + ************************************************************************* */ +export function clickOnAddFilterInModal() { + return cy + .get(nativeFilters.addFilterButton.button) + .first() + .click() + .then(() => { + cy.get(nativeFilters.addFilterButton.dropdownItem) + .contains('Filter') + .click({ force: true }); + }); +} + +/** ************************************************************************ + * Fills value native filter form with basic information + * @param {string} name name for filter + * @param {string} dataset which dataset should be used + * @param {string} filterColumn which column should be used + * @returns {None} + * @summary helper for filling value native filter form + ************************************************************************* */ +export function fillValueNativeFilterForm( + name: string, + dataset: string, + filterColumn: string, +) { + cy.get(nativeFilters.modal.container) + .find(nativeFilters.filtersPanel.filterName) + .last() + .click({ scrollBehavior: false }) + .type(name, { scrollBehavior: false }); + cy.get(nativeFilters.modal.container) + .find(nativeFilters.filtersPanel.datasetName) + .last() + .click({ scrollBehavior: false }) + .type(`${dataset}{enter}`, { scrollBehavior: false }); + cy.get(nativeFilters.silentLoading).should('not.exist'); + cy.get(nativeFilters.filtersPanel.filterInfoInput) + .last() + .should('be.visible') + .click({ force: true }); + cy.get(nativeFilters.filtersPanel.filterInfoInput).last().type(filterColumn); + cy.get(nativeFilters.filtersPanel.inputDropdown) + .should('be.visible', { timeout: 20000 }) + .last() + .click(); +} +/** ************************************************************************ + * Get native filter placeholder e.g 9 options + * @param {number} index which input it fills + * @returns cy object for assertions + * @summary helper for getting placeholder value + ************************************************************************* */ +export function getNativeFilterPlaceholderWithIndex(index: number) { + return cy.get(nativeFilters.filtersPanel.columnEmptyInput).eq(index); +} + +/** ************************************************************************ + * Apply native filter value from dashboard view + * @param {number} index which input it fills + * @param {string} value what is filter value + * @returns {null} + * @summary put value to nth native filter input in view + ************************************************************************* */ +export function applyNativeFilterValueWithIndex(index: number, value: string) { + cy.get(nativeFilters.filterFromDashboardView.filterValueInput) + .eq(index) + .parent() + .should('be.visible', { timeout: 10000 }) + .type(`${value}{enter}`); + // click the title to dismiss shown options + cy.get(nativeFilters.filterFromDashboardView.filterName).eq(index).click(); +} + +/** ************************************************************************ + * Fills parent filter input + * @param {number} index which input it fills + * @param {string} value on which filter it depends on + * @returns {null} + * @summary takes first or second input and modify the depends on filter value + ************************************************************************* */ +export function addParentFilterWithValue(index: number, value: string) { + return cy + .get(nativeFilters.filterConfigurationSections.displayedSection) + .within(() => { + cy.get('input[aria-label="Limit type"]') + .eq(index) + .click({ force: true }) + .type(`${value}{enter}`, { delay: 30, force: true }); + }); +} diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts index 433b4a65e33cb..2177c9c4fe4cd 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts @@ -22,7 +22,17 @@ import { nativeFilters, exploreView, } from 'cypress/support/directories'; -import { testItems } from './dashboard.helper'; +import { + cleanUp, + testItems, + WORLD_HEALTH_CHARTS, + waitForChartLoad, + clickOnAddFilterInModal, + fillValueNativeFilterForm, + getNativeFilterPlaceholderWithIndex, + addParentFilterWithValue, + applyNativeFilterValueWithIndex, +} from './dashboard.helper'; import { DASHBOARD_LIST } from '../dashboard_list/dashboard_list.helper'; import { CHART_LIST } from '../chart_list/chart_list.helper'; import { FORM_DATA_DEFAULTS } from '../explore/visualizations/shared.helper'; @@ -39,21 +49,27 @@ const milliseconds = new Date().getTime(); const dashboard = `Test Dashboard${milliseconds}`; describe('Nativefilters Sanity test', () => { - before(() => { + beforeEach(() => { cy.login(); + cleanUp(); cy.intercept('/api/v1/dashboard/?q=**').as('dashboardsList'); cy.intercept('POST', '**/copy_dash/*').as('copy'); cy.intercept('/api/v1/dashboard/*').as('dashboard'); - cy.request( - 'api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:100)', - ).then(xhr => { - const dashboards = xhr.body.result; + cy.intercept('GET', '**/api/v1/dataset/**').as('datasetLoad'); + cy.intercept('**/api/v1/dashboard/?q=**').as('dashboardsList'); + cy.visit('dashboard/list/'); + cy.contains('Actions'); + cy.wait('@dashboardsList').then(xhr => { + const dashboards = xhr.response?.body.result; + /* eslint-disable no-unused-expressions */ + expect(dashboards).not.to.be.undefined; const worldBankDashboard = dashboards.find( (d: { dashboard_title: string }) => d.dashboard_title === "World Bank's Data", ); cy.visit(worldBankDashboard.url); }); + WORLD_HEALTH_CHARTS.forEach(waitForChartLoad); cy.get(dashboardView.threeDotsMenuIcon).should('be.visible').click(); cy.get(dashboardView.saveAsMenuOption).should('be.visible').click(); cy.get(dashboardView.saveModal.dashboardNameInput) @@ -65,19 +81,10 @@ describe('Nativefilters Sanity test', () => { .its('response.statusCode') .should('eq', 200); }); - beforeEach(() => { - cy.login(); - cy.request( - 'api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:100)', - ).then(xhr => { - const dashboards = xhr.body.result; - const testDashboard = dashboards.find( - (d: { dashboard_title: string }) => - d.dashboard_title === testItems.dashboard, - ); - cy.visit(testDashboard.url); - }); + afterEach(() => { + cleanUp(); }); + it('User can expand / retract native filter sidebar on a dashboard', () => { cy.get(nativeFilters.createFilterButton).should('not.exist'); cy.get(nativeFilters.filterFromDashboardView.expand) @@ -390,15 +397,6 @@ describe('Nativefilters Sanity test', () => { cy.get('.line').within(() => { cy.contains('United States').should('be.visible'); }); - - // clean up the default setting - cy.get(nativeFilters.filterFromDashboardView.expand).click({ force: true }); - cy.get(nativeFilters.filterFromDashboardView.createFilterButton).click(); - cy.contains('Filter has default value').click(); - cy.get(nativeFilters.modal.footer) - .find(nativeFilters.modal.saveButton) - .should('be.visible') - .click({ force: true }); }); it('User can create a time grain filter', () => { @@ -565,6 +563,51 @@ describe('Nativefilters Sanity test', () => { .should('be.visible', { timeout: 40000 }) .contains('country_name'); }); + + it('User can create parent filters using "Values are dependent on other filters"', () => { + cy.get(nativeFilters.filterFromDashboardView.expand) + .should('be.visible') + .click({ force: true }); + cy.get(nativeFilters.filterFromDashboardView.createFilterButton).click(); + // Create parent filter 'region'. + fillValueNativeFilterForm('region', 'wb_health_population', 'region'); + // Create filter 'country_name' depend on region filter. + clickOnAddFilterInModal(); + fillValueNativeFilterForm( + 'country_name', + 'wb_health_population', + 'country_name', + ); + cy.get(nativeFilters.filterConfigurationSections.displayedSection).within( + () => { + cy.contains('Values are dependent on other filters') + .should('be.visible') + .click(); + }, + ); + addParentFilterWithValue(0, 'region'); + cy.wait(1000); + cy.get(nativeFilters.modal.footer) + .contains('Save') + .should('be.visible') + .click(); + // Validate both filter in dashboard view. + WORLD_HEALTH_CHARTS.forEach(waitForChartLoad); + ['region', 'country_name'].forEach(it => { + cy.get(nativeFilters.filterFromDashboardView.filterName) + .contains(it) + .should('be.visible'); + }); + getNativeFilterPlaceholderWithIndex(1) + .invoke('text') + .should('equal', '214 options', { timeout: 20000 }); + // apply first filter value and validate 2nd filter is depden on 1st filter. + applyNativeFilterValueWithIndex(0, 'East Asia & Pacific'); + + getNativeFilterPlaceholderWithIndex(0).should('have.text', '36 options', { + timeout: 20000, + }); + }); }); xdescribe('Nativefilters', () => { diff --git a/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts b/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts index 1b723ec65d406..97dfd2945aaf5 100644 --- a/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts @@ -121,15 +121,13 @@ describe('Test datatable', () => { cy.visitChartByName('Daily Totals'); }); it('Data Pane opens and loads results', () => { - cy.get('[data-test="data-tab"]').click(); + cy.contains('Results').click(); cy.get('[data-test="row-count-label"]').contains('26 rows retrieved'); - cy.contains('View results'); cy.get('.ant-empty-description').should('not.exist'); }); it('Datapane loads view samples', () => { - cy.get('[data-test="data-tab"]').click(); - cy.contains('View samples').click(); - cy.get('[data-test="row-count-label"]').contains('10k rows retrieved'); + cy.contains('Samples').click(); + cy.get('[data-test="row-count-label"]').contains('1k rows retrieved'); cy.get('.ant-empty-description').should('not.exist'); }); }); diff --git a/superset-frontend/cypress-base/cypress/integration/explore/visualizations/time_table.js b/superset-frontend/cypress-base/cypress/integration/explore/visualizations/time_table.js index ea1353bec8dba..7da90027856f6 100644 --- a/superset-frontend/cypress-base/cypress/integration/explore/visualizations/time_table.js +++ b/superset-frontend/cypress-base/cypress/integration/explore/visualizations/time_table.js @@ -47,7 +47,7 @@ describe('Visualization > Time TableViz', () => { waitAlias: '@getJson', querySubstring: NUM_METRIC.label, }); - cy.get('.time-table').within(() => { + cy.get('[data-test="time-table"]').within(() => { cy.get('span').contains('Sum(num)'); cy.get('span').contains('COUNT(*)'); }); @@ -75,7 +75,7 @@ describe('Visualization > Time TableViz', () => { waitAlias: '@getJson', querySubstring: NUM_METRIC.label, }); - cy.get('.time-table').within(() => { + cy.get('[data-test="time-table"]').within(() => { cy.get('td').contains('boy'); cy.get('td').contains('girl'); }); @@ -112,7 +112,7 @@ describe('Visualization > Time TableViz', () => { waitAlias: '@getJson', querySubstring: NUM_METRIC.label, }); - cy.get('.time-table').within(() => { + cy.get('[data-test="time-table"]').within(() => { cy.get('th').contains('Current'); cy.get('th').contains('Last Year'); cy.get('th').contains('YoY'); diff --git a/superset-frontend/cypress-base/cypress/integration/sqllab/query.test.ts b/superset-frontend/cypress-base/cypress/integration/sqllab/query.test.ts index ea43c66f97a61..f5033313fcaa2 100644 --- a/superset-frontend/cypress-base/cypress/integration/sqllab/query.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/sqllab/query.test.ts @@ -37,8 +37,8 @@ describe('SqlLab query panel', () => { const sampleResponse = { status: 'success', data: [{ '?column?': 1 }], - columns: [{ name: '?column?', type: 'INT', is_date: false }], - selected_columns: [{ name: '?column?', type: 'INT', is_date: false }], + columns: [{ name: '?column?', type: 'INT', is_dttm: false }], + selected_columns: [{ name: '?column?', type: 'INT', is_dttm: false }], expanded_columns: [], }; diff --git a/superset-frontend/cypress-base/cypress/support/index.d.ts b/superset-frontend/cypress-base/cypress/support/index.d.ts index fdacf3232ba15..eca68a7ced7fe 100644 --- a/superset-frontend/cypress-base/cypress/support/index.d.ts +++ b/superset-frontend/cypress-base/cypress/support/index.d.ts @@ -47,6 +47,20 @@ declare namespace Cypress { querySubstring?: string | RegExp; chartSelector?: JQuery.Selector; }): cy; + + /** + * Get + */ + getDashboards(): cy; + getCharts(): cy; + + /** + * Delete + */ + deleteDashboard(id: number): cy; + deleteDashboardByName(name: string): cy; + deleteChartByName(name: string): cy; + deleteChart(id: number): cy; } } diff --git a/superset-frontend/cypress-base/cypress/support/index.ts b/superset-frontend/cypress-base/cypress/support/index.ts index e22f69975e96f..905d00df9f81c 100644 --- a/superset-frontend/cypress-base/cypress/support/index.ts +++ b/superset-frontend/cypress-base/cypress/support/index.ts @@ -19,6 +19,7 @@ import '@cypress/code-coverage/support'; const BASE_EXPLORE_URL = '/superset/explore/?form_data='; +const TokenName = Cypress.env('TOKEN_NAME'); /* eslint-disable consistent-return */ Cypress.on('uncaught:exception', err => { @@ -102,3 +103,88 @@ Cypress.Commands.add( return cy; }, ); + +Cypress.Commands.add('deleteDashboardByName', (name: string) => + cy.getDashboards().then((dashboards: any) => { + dashboards?.forEach((element: any) => { + if (element.dashboard_title === name) { + const elementId = element.id; + cy.deleteDashboard(elementId); + } + }); + }), +); + +Cypress.Commands.add('deleteDashboard', (id: number) => + cy + .request({ + method: 'DELETE', + url: `api/v1/dashboard/${id}`, + headers: { + Cookie: `csrf_access_token=${window.localStorage.getItem( + 'access_token', + )}`, + 'Content-Type': 'application/json', + Authorization: `Bearer ${TokenName}`, + 'X-CSRFToken': `${window.localStorage.getItem('access_token')}`, + Referer: `${Cypress.config().baseUrl}/`, + }, + }) + .then(resp => resp), +); + +Cypress.Commands.add('getDashboards', () => + cy + .request({ + method: 'GET', + url: `api/v1/dashboard/`, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${TokenName}`, + }, + }) + .then(resp => resp.body.result), +); + +Cypress.Commands.add('deleteChart', (id: number) => + cy + .request({ + method: 'DELETE', + url: `api/v1/chart/${id}`, + headers: { + Cookie: `csrf_access_token=${window.localStorage.getItem( + 'access_token', + )}`, + 'Content-Type': 'application/json', + Authorization: `Bearer ${TokenName}`, + 'X-CSRFToken': `${window.localStorage.getItem('access_token')}`, + Referer: `${Cypress.config().baseUrl}/`, + }, + failOnStatusCode: false, + }) + .then(resp => resp), +); + +Cypress.Commands.add('getCharts', () => + cy + .request({ + method: 'GET', + url: `api/v1/chart/`, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${TokenName}`, + }, + }) + .then(resp => resp.body.result), +); + +Cypress.Commands.add('deleteChartByName', (name: string) => + cy.getCharts().then((slices: any) => { + slices?.forEach((element: any) => { + if (element.slice_name === name) { + const elementId = element.id; + cy.deleteChart(elementId); + } + }); + }), +); diff --git a/superset-frontend/cypress-base/cypress/utils/parsePostForm.ts b/superset-frontend/cypress-base/cypress/utils/parsePostForm.ts index 0a818d18d557b..2d85a8681a11d 100644 --- a/superset-frontend/cypress-base/cypress/utils/parsePostForm.ts +++ b/superset-frontend/cypress-base/cypress/utils/parsePostForm.ts @@ -22,7 +22,7 @@ export default function parsePostForm(requestBody: ArrayBuffer) { type ParsedFields = Record; if (requestBody.constructor.name !== 'ArrayBuffer') { - return requestBody as unknown as ParsedFields; + return requestBody; } const lines = new TextDecoder('utf-8').decode(requestBody).split('\n'); const fields: ParsedFields = {}; diff --git a/superset-frontend/cypress-base/package-lock.json b/superset-frontend/cypress-base/package-lock.json index 2b89c19c14302..d3a3153eff7f0 100644 --- a/superset-frontend/cypress-base/package-lock.json +++ b/superset-frontend/cypress-base/package-lock.json @@ -6290,9 +6290,9 @@ } }, "node_modules/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" }, "node_modules/mkdirp-classic": { "version": "0.5.3", @@ -13335,9 +13335,9 @@ } }, "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" }, "mkdirp-classic": { "version": "0.5.3", diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 59582524ea16a..51c7e12f30991 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -26,7 +26,6 @@ "@superset-ui/legacy-plugin-chart-chord": "file:./plugins/legacy-plugin-chart-chord", "@superset-ui/legacy-plugin-chart-country-map": "file:./plugins/legacy-plugin-chart-country-map", "@superset-ui/legacy-plugin-chart-event-flow": "file:./plugins/legacy-plugin-chart-event-flow", - "@superset-ui/legacy-plugin-chart-force-directed": "file:./plugins/legacy-plugin-chart-force-directed", "@superset-ui/legacy-plugin-chart-heatmap": "file:./plugins/legacy-plugin-chart-heatmap", "@superset-ui/legacy-plugin-chart-histogram": "file:./plugins/legacy-plugin-chart-histogram", "@superset-ui/legacy-plugin-chart-horizon": "file:./plugins/legacy-plugin-chart-horizon", @@ -51,6 +50,7 @@ "@superset-ui/switchboard": "file:./packages/superset-ui-switchboard", "@vx/responsive": "^0.0.195", "abortcontroller-polyfill": "^1.1.9", + "ace-builds": "^1.4.14", "antd": "^4.9.4", "array-move": "^2.2.1", "babel-plugin-typescript-to-proptypes": "^2.0.0", @@ -245,7 +245,7 @@ "jsdom": "^16.4.0", "lerna": "^4.0.0", "less": "^3.12.2", - "less-loader": "^5.0.0", + "less-loader": "^10.2.0", "mini-css-extract-plugin": "^2.3.0", "mock-socket": "^9.0.3", "node-fetch": "^2.6.1", @@ -275,7 +275,7 @@ }, "engines": { "node": "^16.9.1", - "npm": "^7.5.4" + "npm": "^7.5.4 || ^8.1.2" } }, "buildtools/eslint-plugin-theme-colors": { @@ -2705,6 +2705,29 @@ "node": ">=0.1.95" } }, + "node_modules/@cspotcode/source-map-consumer": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz", + "integrity": "sha512-41qniHzTU8yAGbCp04ohlmSrZf8bkf/iJsl3V0dRGsQN/5GFfx+LbCSsCpp2gqrqjTVg/K6O8ycoV35JIwAzAg==", + "dev": true, + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.7.0.tgz", + "integrity": "sha512-X4xqRHqN8ACt2aHVe51OxeA2HjbcL4MqFqXkrmQszJ1NOUuUu5u6Vqx/0lZSVNku7velL5FC/s5uEAj1lsBMhA==", + "dev": true, + "peer": true, + "dependencies": { + "@cspotcode/source-map-consumer": "0.8.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@ctrl/tinycolor": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.3.1.tgz", @@ -21907,10 +21930,6 @@ "resolved": "plugins/legacy-plugin-chart-event-flow", "link": true }, - "node_modules/@superset-ui/legacy-plugin-chart-force-directed": { - "resolved": "plugins/legacy-plugin-chart-force-directed", - "link": true - }, "node_modules/@superset-ui/legacy-plugin-chart-heatmap": { "resolved": "plugins/legacy-plugin-chart-heatmap", "link": true @@ -22410,6 +22429,34 @@ "node": ">=10.13.0" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz", + "integrity": "sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==", + "dev": true, + "peer": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.9.tgz", + "integrity": "sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw==", + "dev": true, + "peer": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.1.tgz", + "integrity": "sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==", + "dev": true, + "peer": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.2.tgz", + "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==", + "dev": true, + "peer": true + }, "node_modules/@types/aria-query": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.0.tgz", @@ -24403,14 +24450,14 @@ } }, "node_modules/ace-builds": { - "version": "1.4.13", - "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.4.13.tgz", - "integrity": "sha512-SOLzdaQkY6ecPKYRDDg+MY1WoGgXA34cIvYJNNoBMGGUswHmlauU2Hy0UL96vW0Fs/LgFbMUjD+6vqzWTldIYQ==" + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.4.14.tgz", + "integrity": "sha512-NBOQlm9+7RBqRqZwimpgquaLeTJFayqb9UEPtTkpC3TkkwDnlsT/TwsCC0svjt9kEZ6G9mH5AEOHSz6Q/HrzQQ==" }, "node_modules/acorn": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz", - "integrity": "sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", + "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", "bin": { "acorn": "bin/acorn" }, @@ -24428,6 +24475,18 @@ "acorn-walk": "^7.1.1" } }, + "node_modules/acorn-globals/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/acorn-import-assertions": { "version": "1.7.6", "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.7.6.tgz", @@ -25004,6 +25063,19 @@ "resolved": "https://registry.npmjs.org/app-root-dir/-/app-root-dir-1.0.2.tgz", "integrity": "sha1-OBh+wt6nV3//Az/8sSFyaS/24Rg=" }, + "node_modules/append-transform": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", + "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, + "peer": true, + "dependencies": { + "default-require-extensions": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/aproba": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", @@ -25030,6 +25102,13 @@ ], "peer": true }, + "node_modules/archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", + "dev": true, + "peer": true + }, "node_modules/are-we-there-yet": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", @@ -25040,6 +25119,13 @@ "readable-stream": "^2.0.6" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "peer": true + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -27119,6 +27205,48 @@ "node": ">=6" } }, + "node_modules/caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "peer": true, + "dependencies": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/caching-transform/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "peer": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caching-transform/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -29575,6 +29703,13 @@ "react": "^0.14.0 || ^15.0.0 || ^16.0.0" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "peer": true + }, "node_modules/cross-env": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-5.2.0.tgz", @@ -31622,6 +31757,29 @@ "node": ">= 8" } }, + "node_modules/default-require-extensions": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.0.tgz", + "integrity": "sha512-ek6DpXq/SCpvjhpFsLFRVtIxJCRw6fUR42lYMVZuUMK7n8eMz4Uh5clckdBjEpLhn/gEBZo7hDJnJcwdKLKQjg==", + "dev": true, + "peer": true, + "dependencies": { + "strip-bom": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/default-require-extensions/node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/defaults": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", @@ -32151,9 +32309,9 @@ } }, "node_modules/echarts": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.3.1.tgz", - "integrity": "sha512-nWdlbgX3OVY0hpqncSvp0gDt1FRSKWn7lsWEH+PHmfCuvE0QmSw17pczQvm8AvawnLEkmf1Cts7YwQJZNC0AEQ==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.3.2.tgz", + "integrity": "sha512-LWCt7ohOKdJqyiBJ0OGBmE9szLdfA9sGcsMEi+GGoc6+Xo75C+BkcT/6NNGRHAWtnQl2fNow05AQjznpap28TQ==", "dependencies": { "tslib": "2.3.0", "zrender": "5.3.1" @@ -32905,6 +33063,13 @@ "node": ">=0.4.0" } }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "peer": true + }, "node_modules/es6-shim": { "version": "0.35.6", "resolved": "https://registry.npmjs.org/es6-shim/-/es6-shim-0.35.6.tgz", @@ -34534,6 +34699,17 @@ "node": ">=0.4.0" } }, + "node_modules/falafel/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/falafel/node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -36929,6 +37105,36 @@ "minimalistic-assert": "^1.0.1" } }, + "node_modules/hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "peer": true, + "dependencies": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasha/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/hast-to-hyperscript": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz", @@ -38980,6 +39186,19 @@ "node": ">=8" } }, + "node_modules/istanbul-lib-hook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", + "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, + "peer": true, + "dependencies": { + "append-transform": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/istanbul-lib-instrument": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", @@ -39004,6 +39223,128 @@ "semver": "bin/semver.js" } }, + "node_modules/istanbul-lib-processinfo": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.2.tgz", + "integrity": "sha512-kOwpa7z9hme+IBPZMzQ5vdQj8srYgAtaRqeI48NGmAQ+/5yKiHLV0QbYqQpxsdEF0+w14SoB8YbnHKcXE2KnYw==", + "dev": true, + "peer": true, + "dependencies": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.0", + "istanbul-lib-coverage": "^3.0.0-alpha.1", + "make-dir": "^3.0.0", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^3.3.3" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "peer": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "peer": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/istanbul-lib-report": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", @@ -41655,6 +41996,18 @@ } } }, + "node_modules/jsdom/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/jsdom/node_modules/escodegen": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", @@ -42130,30 +42483,23 @@ } }, "node_modules/less-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-5.0.0.tgz", - "integrity": "sha512-bquCU89mO/yWLaUq0Clk7qCsKhsF/TZpJUzETRvJa9KSVEL9SO3ovCvdEHISBhrC81OwC8QSVX7E0bzElZj9cg==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-10.2.0.tgz", + "integrity": "sha512-AV5KHWvCezW27GT90WATaDnfXBv99llDbtaj4bshq6DvAihMdNjaPDcUMa6EXKLRF+P2opFenJp89BXg91XLYg==", "dev": true, "dependencies": { - "clone": "^2.1.1", - "loader-utils": "^1.1.0", - "pify": "^4.0.1" + "klona": "^2.0.4" }, "engines": { - "node": ">= 4.8.0" + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "less": "^2.3.1 || ^3.0.0", - "webpack": "^2.0.0 || ^3.0.0 || ^4.0.0" - } - }, - "node_modules/less-loader/node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "engines": { - "node": ">=6" + "less": "^3.5.0 || ^4.0.0", + "webpack": "^5.0.0" } }, "node_modules/less/node_modules/source-map": { @@ -43208,6 +43554,13 @@ "node": ">=6" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "peer": true + }, "node_modules/make-fetch-happen": { "version": "8.0.14", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-8.0.14.tgz", @@ -45108,6 +45461,19 @@ "node": ">= 8" } }, + "node_modules/node-preload": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", + "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, + "peer": true, + "dependencies": { + "process-on-spawn": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/node-releases": { "version": "1.1.75", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.75.tgz", @@ -45614,6 +45980,204 @@ "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==", "dev": true }, + "node_modules/nyc": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", + "integrity": "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==", + "dev": true, + "peer": true, + "dependencies": { + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^2.0.0", + "get-package-type": "^0.1.0", + "glob": "^7.1.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^4.0.0", + "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "make-dir": "^3.0.0", + "node-preload": "^0.2.1", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", + "yargs": "^15.0.2" + }, + "bin": { + "nyc": "bin/nyc.js" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/nyc/node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "peer": true, + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/nyc/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "peer": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "peer": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "peer": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nyc/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "peer": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nyc/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "peer": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "peer": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "peer": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -46262,6 +46826,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-hash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", + "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/pacote": { "version": "12.0.2", "resolved": "https://registry.npmjs.org/pacote/-/pacote-12.0.2.tgz", @@ -47331,6 +47911,19 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" }, + "node_modules/process-on-spawn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", + "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==", + "dev": true, + "peer": true, + "dependencies": { + "fromentries": "^1.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -50203,6 +50796,19 @@ "node": ">= 0.10" } }, + "node_modules/release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha1-CXALflB0Mpc5Mw5TXFqQ+2eFFzA=", + "dev": true, + "peer": true, + "dependencies": { + "es6-error": "^4.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/remark-external-links": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/remark-external-links/-/remark-external-links-8.0.0.tgz", @@ -52053,6 +52659,66 @@ "trim": "0.0.1" } }, + "node_modules/spawn-wrap": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", + "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, + "peer": true, + "dependencies": { + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/spawn-wrap/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "peer": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/spawn-wrap/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/spawn-wrap/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/spdx-correct": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz", @@ -53967,6 +54633,60 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/ts-node": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.7.0.tgz", + "integrity": "sha512-TbIGS4xgJoX2i3do417KSaep1uRAW/Lu+WAL2doDHC0D6ummjirVOXU5/7aiZotbQ5p1Zp9tP7U6cYhA0O7M8A==", + "dev": true, + "peer": true, + "dependencies": { + "@cspotcode/source-map-support": "0.7.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.0", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ts-pnp": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz", @@ -54498,9 +55218,9 @@ } }, "node_modules/urijs": { - "version": "1.19.8", - "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.8.tgz", - "integrity": "sha512-iIXHrjomQ0ZCuDRy44wRbyTZVnfVNLVo3Ksz1yxNyE5wV1IDZW2S5Jszy45DTlw/UdsnRT7DyDhIz7Gy+vJumw==" + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==" }, "node_modules/urix": { "version": "0.1.0", @@ -54676,6 +55396,13 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.0.tgz", + "integrity": "sha512-mpSYqfsFvASnSn5qMiwrr4VKfumbPyONLCOPmsR3A6pTY/r0+tSaVbgPWSAIuzbk3lCTa+FForeTiO+wBQGkjA==", + "dev": true, + "peer": true + }, "node_modules/v8-to-istanbul": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-7.0.0.tgz", @@ -55105,18 +55832,6 @@ "node": ">= 10.13.0" } }, - "node_modules/webpack-bundle-analyzer/node_modules/acorn": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.5.0.tgz", - "integrity": "sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/webpack-bundle-analyzer/node_modules/acorn-walk": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", @@ -55767,17 +56482,6 @@ "@xtuc/long": "4.2.2" } }, - "node_modules/webpack/node_modules/acorn": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.5.0.tgz", - "integrity": "sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/webpack/node_modules/enhanced-resolve": { "version": "5.8.2", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.8.2.tgz", @@ -58301,6 +59005,16 @@ "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==", "dev": true }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -58467,6 +59181,8 @@ "license": "Apache-2.0", "dependencies": { "@react-icons/all-files": "^4.1.0", + "@types/enzyme": "^3.10.5", + "@types/react": "*", "lodash": "^4.17.15", "prop-types": "^15.7.2" }, @@ -58479,10 +59195,11 @@ "@testing-library/react": "^11.2.0", "@testing-library/react-hooks": "^5.0.3", "@testing-library/user-event": "^12.7.0", - "@types/enzyme": "^3.10.5", - "@types/react": "*", + "ace-builds": "^1.4.14", "antd": "^4.9.4", + "brace": "^0.11.1", "react": "^16.13.1", + "react-ace": "^9.4.4", "react-dom": "^16.13.1" } }, @@ -58652,7 +59369,6 @@ "@superset-ui/legacy-plugin-chart-chord": "*", "@superset-ui/legacy-plugin-chart-country-map": "*", "@superset-ui/legacy-plugin-chart-event-flow": "*", - "@superset-ui/legacy-plugin-chart-force-directed": "*", "@superset-ui/legacy-plugin-chart-heatmap": "*", "@superset-ui/legacy-plugin-chart-histogram": "*", "@superset-ui/legacy-plugin-chart-horizon": "*", @@ -59028,6 +59744,7 @@ "prop-types": "^15.6.2" }, "peerDependencies": { + "@emotion/react": "^11.4.1", "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", "react": "^16.13.1" @@ -59066,7 +59783,8 @@ }, "peerDependencies": { "@superset-ui/chart-controls": "*", - "@superset-ui/core": "*" + "@superset-ui/core": "*", + "react": "^16.13.1" } }, "plugins/legacy-plugin-chart-country-map/node_modules/d3-array": { @@ -59093,16 +59811,11 @@ }, "plugins/legacy-plugin-chart-force-directed": { "name": "@superset-ui/legacy-plugin-chart-force-directed", - "version": "0.18.25", - "license": "Apache-2.0", + "version": "0.0.1", + "extraneous": true, "dependencies": { "d3": "^3.5.17", "prop-types": "^15.7.2" - }, - "peerDependencies": { - "@superset-ui/chart-controls": "*", - "@superset-ui/core": "*", - "react": "^16.13.1" } }, "plugins/legacy-plugin-chart-heatmap": { @@ -59116,8 +59829,10 @@ "prop-types": "^15.6.2" }, "peerDependencies": { + "@emotion/react": "^11.4.1", "@superset-ui/chart-controls": "*", - "@superset-ui/core": "*" + "@superset-ui/core": "*", + "react": "^16.13.1" } }, "plugins/legacy-plugin-chart-histogram": { @@ -59321,6 +60036,7 @@ "prop-types": "^15.6.2" }, "peerDependencies": { + "@emotion/react": "^11.4.1", "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", "react": "^16.13.1" @@ -59352,7 +60068,8 @@ }, "peerDependencies": { "@superset-ui/chart-controls": "*", - "@superset-ui/core": "*" + "@superset-ui/core": "*", + "react": "^16.13.1" } }, "plugins/legacy-plugin-chart-sunburst": { @@ -59365,7 +60082,8 @@ }, "peerDependencies": { "@superset-ui/chart-controls": "*", - "@superset-ui/core": "*" + "@superset-ui/core": "*", + "react": "^16.13.1" } }, "plugins/legacy-plugin-chart-time-table": { @@ -59395,7 +60113,8 @@ }, "peerDependencies": { "@superset-ui/chart-controls": "*", - "@superset-ui/core": "*" + "@superset-ui/core": "*", + "react": "^16.13.1" } }, "plugins/legacy-plugin-chart-world-map": { @@ -59536,7 +60255,7 @@ "license": "Apache-2.0", "dependencies": { "d3-array": "^1.2.0", - "echarts": "^5.3.1", + "echarts": "^5.3.2", "lodash": "^4.17.15", "moment": "^2.26.0" }, @@ -59781,14 +60500,7 @@ "tools/eslint-plugin-theme-colors": { "version": "1.0.0", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "lodash": "^4.17.21" - }, - "engines": { - "node": "^16.9.1", - "npm": "^7.5.4" - } + "license": "Apache-2.0" } }, "dependencies": { @@ -61464,6 +62176,23 @@ "minimist": "^1.2.0" } }, + "@cspotcode/source-map-consumer": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz", + "integrity": "sha512-41qniHzTU8yAGbCp04ohlmSrZf8bkf/iJsl3V0dRGsQN/5GFfx+LbCSsCpp2gqrqjTVg/K6O8ycoV35JIwAzAg==", + "dev": true, + "peer": true + }, + "@cspotcode/source-map-support": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.7.0.tgz", + "integrity": "sha512-X4xqRHqN8ACt2aHVe51OxeA2HjbcL4MqFqXkrmQszJ1NOUuUu5u6Vqx/0lZSVNku7velL5FC/s5uEAj1lsBMhA==", + "dev": true, + "peer": true, + "requires": { + "@cspotcode/source-map-consumer": "0.8.0" + } + }, "@ctrl/tinycolor": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.3.1.tgz", @@ -76177,6 +76906,8 @@ "version": "file:packages/superset-ui-chart-controls", "requires": { "@react-icons/all-files": "^4.1.0", + "@types/enzyme": "^3.10.5", + "@types/react": "*", "lodash": "^4.17.15", "prop-types": "^15.7.2" } @@ -76638,13 +77369,6 @@ "prop-types": "^15.6.2" } }, - "@superset-ui/legacy-plugin-chart-force-directed": { - "version": "file:plugins/legacy-plugin-chart-force-directed", - "requires": { - "d3": "^3.5.17", - "prop-types": "^15.7.2" - } - }, "@superset-ui/legacy-plugin-chart-heatmap": { "version": "file:plugins/legacy-plugin-chart-heatmap", "requires": { @@ -76931,7 +77655,7 @@ "version": "file:plugins/plugin-chart-echarts", "requires": { "d3-array": "^1.2.0", - "echarts": "^5.3.1", + "echarts": "^5.3.2", "lodash": "^4.17.15", "moment": "^2.26.0" } @@ -77375,6 +78099,34 @@ "integrity": "sha512-Z6DoceYb/1xSg5+e+ZlPZ9v0N16ZvZ+wYMraFue4HYrE4ttONKtsvruIRf6t9TBR0YvSOfi1hUU0fJfBLCDYow==", "dev": true }, + "@tsconfig/node10": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz", + "integrity": "sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==", + "dev": true, + "peer": true + }, + "@tsconfig/node12": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.9.tgz", + "integrity": "sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw==", + "dev": true, + "peer": true + }, + "@tsconfig/node14": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.1.tgz", + "integrity": "sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==", + "dev": true, + "peer": true + }, + "@tsconfig/node16": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.2.tgz", + "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==", + "dev": true, + "peer": true + }, "@types/aria-query": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.0.tgz", @@ -79091,14 +79843,14 @@ } }, "ace-builds": { - "version": "1.4.13", - "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.4.13.tgz", - "integrity": "sha512-SOLzdaQkY6ecPKYRDDg+MY1WoGgXA34cIvYJNNoBMGGUswHmlauU2Hy0UL96vW0Fs/LgFbMUjD+6vqzWTldIYQ==" + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.4.14.tgz", + "integrity": "sha512-NBOQlm9+7RBqRqZwimpgquaLeTJFayqb9UEPtTkpC3TkkwDnlsT/TwsCC0svjt9kEZ6G9mH5AEOHSz6Q/HrzQQ==" }, "acorn": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz", - "integrity": "sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg==" + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", + "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==" }, "acorn-globals": { "version": "6.0.0", @@ -79108,6 +79860,14 @@ "requires": { "acorn": "^7.1.1", "acorn-walk": "^7.1.1" + }, + "dependencies": { + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true + } } }, "acorn-import-assertions": { @@ -79570,6 +80330,16 @@ "resolved": "https://registry.npmjs.org/app-root-dir/-/app-root-dir-1.0.2.tgz", "integrity": "sha1-OBh+wt6nV3//Az/8sSFyaS/24Rg=" }, + "append-transform": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", + "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, + "peer": true, + "requires": { + "default-require-extensions": "^3.0.0" + } + }, "aproba": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", @@ -79582,6 +80352,13 @@ "dev": true, "peer": true }, + "archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", + "dev": true, + "peer": true + }, "are-we-there-yet": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", @@ -79592,6 +80369,13 @@ "readable-stream": "^2.0.6" } }, + "arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "peer": true + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -81212,6 +81996,38 @@ "dev": true, "peer": true }, + "caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "peer": true, + "requires": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + }, + "dependencies": { + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "peer": true, + "requires": { + "semver": "^6.0.0" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "peer": true + } + } + }, "call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -83134,6 +83950,13 @@ "warning": "^4.0.3" } }, + "create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "peer": true + }, "cross-env": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-5.2.0.tgz", @@ -84679,6 +85502,25 @@ } } }, + "default-require-extensions": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.0.tgz", + "integrity": "sha512-ek6DpXq/SCpvjhpFsLFRVtIxJCRw6fUR42lYMVZuUMK7n8eMz4Uh5clckdBjEpLhn/gEBZo7hDJnJcwdKLKQjg==", + "dev": true, + "peer": true, + "requires": { + "strip-bom": "^4.0.0" + }, + "dependencies": { + "strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "peer": true + } + } + }, "defaults": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", @@ -85138,9 +85980,9 @@ } }, "echarts": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.3.1.tgz", - "integrity": "sha512-nWdlbgX3OVY0hpqncSvp0gDt1FRSKWn7lsWEH+PHmfCuvE0QmSw17pczQvm8AvawnLEkmf1Cts7YwQJZNC0AEQ==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.3.2.tgz", + "integrity": "sha512-LWCt7ohOKdJqyiBJ0OGBmE9szLdfA9sGcsMEi+GGoc6+Xo75C+BkcT/6NNGRHAWtnQl2fNow05AQjznpap28TQ==", "requires": { "tslib": "2.3.0", "zrender": "5.3.1" @@ -85762,6 +86604,13 @@ "resolved": "https://registry.npmjs.org/es5-shim/-/es5-shim-4.6.5.tgz", "integrity": "sha512-vfQ4UAai8szn0sAubCy97xnZ4sJVDD1gt/Grn736hg8D7540wemIb1YPrYZSTqlM2H69EQX1or4HU/tSwRTI3w==" }, + "es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "peer": true + }, "es6-shim": { "version": "0.35.6", "resolved": "https://registry.npmjs.org/es6-shim/-/es6-shim-0.35.6.tgz", @@ -86468,10 +87317,7 @@ } }, "eslint-plugin-theme-colors": { - "version": "file:tools/eslint-plugin-theme-colors", - "requires": { - "lodash": "^4.17.21" - } + "version": "file:tools/eslint-plugin-theme-colors" }, "eslint-scope": { "version": "5.1.1", @@ -86972,6 +87818,11 @@ "object-keys": "^1.0.6" }, "dependencies": { + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==" + }, "isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -88824,6 +89675,26 @@ "minimalistic-assert": "^1.0.1" } }, + "hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "peer": true, + "requires": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "dependencies": { + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "peer": true + } + } + }, "hast-to-hyperscript": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz", @@ -90359,6 +91230,16 @@ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz", "integrity": "sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==" }, + "istanbul-lib-hook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", + "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, + "peer": true, + "requires": { + "append-transform": "^2.0.0" + } + }, "istanbul-lib-instrument": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", @@ -90379,6 +91260,97 @@ } } }, + "istanbul-lib-processinfo": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.2.tgz", + "integrity": "sha512-kOwpa7z9hme+IBPZMzQ5vdQj8srYgAtaRqeI48NGmAQ+/5yKiHLV0QbYqQpxsdEF0+w14SoB8YbnHKcXE2KnYw==", + "dev": true, + "peer": true, + "requires": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.0", + "istanbul-lib-coverage": "^3.0.0-alpha.1", + "make-dir": "^3.0.0", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^3.3.3" + }, + "dependencies": { + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "peer": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "peer": true, + "requires": { + "semver": "^6.0.0" + } + }, + "p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "peer": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "peer": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "peer": true + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "peer": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "peer": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "peer": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, "istanbul-lib-report": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", @@ -92445,6 +93417,12 @@ "xml-name-validator": "^3.0.0" }, "dependencies": { + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true + }, "escodegen": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", @@ -92823,22 +93801,12 @@ } }, "less-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-5.0.0.tgz", - "integrity": "sha512-bquCU89mO/yWLaUq0Clk7qCsKhsF/TZpJUzETRvJa9KSVEL9SO3ovCvdEHISBhrC81OwC8QSVX7E0bzElZj9cg==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-10.2.0.tgz", + "integrity": "sha512-AV5KHWvCezW27GT90WATaDnfXBv99llDbtaj4bshq6DvAihMdNjaPDcUMa6EXKLRF+P2opFenJp89BXg91XLYg==", "dev": true, "requires": { - "clone": "^2.1.1", - "loader-utils": "^1.1.0", - "pify": "^4.0.1" - }, - "dependencies": { - "pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true - } + "klona": "^2.0.4" } }, "leven": { @@ -93689,6 +94657,13 @@ } } }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "peer": true + }, "make-fetch-happen": { "version": "8.0.14", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-8.0.14.tgz", @@ -95227,6 +96202,16 @@ } } }, + "node-preload": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", + "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, + "peer": true, + "requires": { + "process-on-spawn": "^1.0.0" + } + }, "node-releases": { "version": "1.1.75", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.75.tgz", @@ -95632,6 +96617,155 @@ "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==", "dev": true }, + "nyc": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", + "integrity": "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==", + "dev": true, + "peer": true, + "requires": { + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^2.0.0", + "get-package-type": "^0.1.0", + "glob": "^7.1.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^4.0.0", + "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "make-dir": "^3.0.0", + "node-preload": "^0.2.1", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", + "yargs": "^15.0.2" + }, + "dependencies": { + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "peer": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "peer": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "peer": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "peer": true, + "requires": { + "semver": "^6.0.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "peer": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "peer": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "peer": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "peer": true + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "peer": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "peer": true, + "requires": { + "find-up": "^4.0.0" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "peer": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "peer": true + } + } + }, "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -96109,6 +97243,19 @@ "p-reduce": "^2.0.0" } }, + "package-hash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", + "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, + "peer": true, + "requires": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + } + }, "pacote": { "version": "12.0.2", "resolved": "https://registry.npmjs.org/pacote/-/pacote-12.0.2.tgz", @@ -96938,6 +98085,16 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" }, + "process-on-spawn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", + "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==", + "dev": true, + "peer": true, + "requires": { + "fromentries": "^1.2.0" + } + }, "progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -99240,6 +100397,16 @@ "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=" }, + "release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha1-CXALflB0Mpc5Mw5TXFqQ+2eFFzA=", + "dev": true, + "peer": true, + "requires": { + "es6-error": "^4.0.1" + } + }, "remark-external-links": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/remark-external-links/-/remark-external-links-8.0.0.tgz", @@ -100683,6 +101850,50 @@ "trim": "0.0.1" } }, + "spawn-wrap": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", + "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, + "peer": true, + "requires": { + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + }, + "dependencies": { + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "peer": true, + "requires": { + "semver": "^6.0.0" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "peer": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "peer": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, "spdx-correct": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz", @@ -102176,6 +103387,37 @@ } } }, + "ts-node": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.7.0.tgz", + "integrity": "sha512-TbIGS4xgJoX2i3do417KSaep1uRAW/Lu+WAL2doDHC0D6ummjirVOXU5/7aiZotbQ5p1Zp9tP7U6cYhA0O7M8A==", + "dev": true, + "peer": true, + "requires": { + "@cspotcode/source-map-support": "0.7.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.0", + "yn": "3.1.1" + }, + "dependencies": { + "acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, + "peer": true + } + } + }, "ts-pnp": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz", @@ -102577,9 +103819,9 @@ } }, "urijs": { - "version": "1.19.8", - "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.8.tgz", - "integrity": "sha512-iIXHrjomQ0ZCuDRy44wRbyTZVnfVNLVo3Ksz1yxNyE5wV1IDZW2S5Jszy45DTlw/UdsnRT7DyDhIz7Gy+vJumw==" + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==" }, "urix": { "version": "0.1.0", @@ -102718,6 +103960,13 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, + "v8-compile-cache-lib": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.0.tgz", + "integrity": "sha512-mpSYqfsFvASnSn5qMiwrr4VKfumbPyONLCOPmsR3A6pTY/r0+tSaVbgPWSAIuzbk3lCTa+FForeTiO+wBQGkjA==", + "dev": true, + "peer": true + }, "v8-to-istanbul": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-7.0.0.tgz", @@ -103175,11 +104424,6 @@ "@xtuc/long": "4.2.2" } }, - "acorn": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.5.0.tgz", - "integrity": "sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==" - }, "enhanced-resolve": { "version": "5.8.2", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.8.2.tgz", @@ -103296,12 +104540,6 @@ "ws": "^7.3.1" }, "dependencies": { - "acorn": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.5.0.tgz", - "integrity": "sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==", - "dev": true - }, "acorn-walk": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", @@ -105484,6 +106722,13 @@ } } }, + "yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "peer": true + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index f122d09464390..c477a1d6e3e15 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -86,7 +86,6 @@ "@superset-ui/legacy-plugin-chart-chord": "file:./plugins/legacy-plugin-chart-chord", "@superset-ui/legacy-plugin-chart-country-map": "file:./plugins/legacy-plugin-chart-country-map", "@superset-ui/legacy-plugin-chart-event-flow": "file:./plugins/legacy-plugin-chart-event-flow", - "@superset-ui/legacy-plugin-chart-force-directed": "file:./plugins/legacy-plugin-chart-force-directed", "@superset-ui/legacy-plugin-chart-heatmap": "file:./plugins/legacy-plugin-chart-heatmap", "@superset-ui/legacy-plugin-chart-histogram": "file:./plugins/legacy-plugin-chart-histogram", "@superset-ui/legacy-plugin-chart-horizon": "file:./plugins/legacy-plugin-chart-horizon", @@ -111,6 +110,7 @@ "@superset-ui/switchboard": "file:./packages/superset-ui-switchboard", "@vx/responsive": "^0.0.195", "abortcontroller-polyfill": "^1.1.9", + "ace-builds": "^1.4.14", "antd": "^4.9.4", "array-move": "^2.2.1", "babel-plugin-typescript-to-proptypes": "^2.0.0", @@ -305,7 +305,7 @@ "jsdom": "^16.4.0", "lerna": "^4.0.0", "less": "^3.12.2", - "less-loader": "^5.0.0", + "less-loader": "^10.2.0", "mini-css-extract-plugin": "^2.3.0", "mock-socket": "^9.0.3", "node-fetch": "^2.6.1", diff --git a/superset-frontend/packages/superset-ui-chart-controls/package.json b/superset-frontend/packages/superset-ui-chart-controls/package.json index bdb6be4daf846..1890a5e38a08b 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/package.json +++ b/superset-frontend/packages/superset-ui-chart-controls/package.json @@ -24,6 +24,8 @@ ], "dependencies": { "@react-icons/all-files": "^4.1.0", + "@types/enzyme": "^3.10.5", + "@types/react": "*", "lodash": "^4.17.15", "prop-types": "^15.7.2" }, @@ -36,10 +38,11 @@ "@testing-library/react": "^11.2.0", "@testing-library/react-hooks": "^5.0.3", "@testing-library/user-event": "^12.7.0", - "@types/enzyme": "^3.10.5", - "@types/react": "*", + "ace-builds": "^1.4.14", "antd": "^4.9.4", + "brace": "^0.11.1", "react": "^16.13.1", + "react-ace": "^9.4.4", "react-dom": "^16.13.1" }, "publishConfig": { diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/components/ColumnOption.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/components/ColumnOption.tsx index dd7775ec4dd06..fce2e8ff2ad07 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/components/ColumnOption.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/components/ColumnOption.tsx @@ -20,10 +20,10 @@ import React, { useState, ReactNode, useLayoutEffect } from 'react'; import { css, styled, SupersetTheme } from '@superset-ui/core'; import { Tooltip } from './Tooltip'; import { ColumnTypeLabel } from './ColumnTypeLabel/ColumnTypeLabel'; -import InfoTooltipWithTrigger from './InfoTooltipWithTrigger'; import CertifiedIconWithTooltip from './CertifiedIconWithTooltip'; import { ColumnMeta } from '../types'; import { getColumnLabelText, getColumnTooltipNode } from './labelUtils'; +import { SQLPopover } from './SQLPopover'; export type ColumnOptionProps = { column: ColumnMeta; @@ -69,17 +69,7 @@ export function ColumnOption({ {getColumnLabelText(column)} - - {hasExpression && ( - - )} - + {hasExpression && } {column.is_certified && ( - {showFormula && ( - + {showFormula && metric.expression && ( + )} {metric.is_certified && ( css` + color: ${theme.colors.grayscale.base}; + font-size: ${theme.typography.sizes.s}px; + & svg { + margin-left: ${theme.gridUnit}px; + margin-right: ${theme.gridUnit}px; + } + `} +`; + +export const SQLPopover = (props: PopoverProps & { sqlExpression: string }) => { + const theme = useTheme(); + return ( + + } + placement="bottomLeft" + arrowPointAtCenter + title={t('SQL expression')} + {...props} + > + + + ); +}; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/flattenOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/flattenOperator.ts index 1348f4b9879fc..2fe732fc83d06 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/flattenOperator.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/flattenOperator.ts @@ -1,4 +1,3 @@ -/* eslint-disable camelcase */ /** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file @@ -23,4 +22,6 @@ import { PostProcessingFactory } from './types'; export const flattenOperator: PostProcessingFactory = ( formData, queryObject, -) => ({ operation: 'flatten' }); +) => ({ + operation: 'flatten', +}); 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 28e7e70070e87..f39d649f8864b 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 @@ -23,6 +23,7 @@ export { timeComparePivotOperator } from './timeComparePivotOperator'; export { sortOperator } from './sortOperator'; export { pivotOperator } from './pivotOperator'; export { resampleOperator } from './resampleOperator'; +export { renameOperator } from './renameOperator'; export { contributionOperator } from './contributionOperator'; export { prophetOperator } from './prophetOperator'; export { boxplotOperator } from './boxplotOperator'; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/renameOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/renameOperator.ts new file mode 100644 index 0000000000000..94dfa70bbc8f2 --- /dev/null +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/renameOperator.ts @@ -0,0 +1,89 @@ +/* eslint-disable camelcase */ +/** + * 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 { + PostProcessingRename, + ensureIsArray, + getMetricLabel, + ComparisionType, +} from '@superset-ui/core'; +import { PostProcessingFactory } from './types'; +import { getMetricOffsetsMap, isTimeComparison } from './utils'; + +export const renameOperator: PostProcessingFactory = ( + formData, + queryObject, +) => { + const metrics = ensureIsArray(queryObject.metrics); + const columns = ensureIsArray(queryObject.columns); + const { x_axis: xAxis } = formData; + // remove or rename top level of column name(metric name) in the MultiIndex when + // 1) only 1 metric + // 2) exist dimentsion + // 3) exist xAxis + // 4) exist time comparison, and comparison type is "actual values" + if ( + metrics.length === 1 && + columns.length > 0 && + (xAxis || queryObject.is_timeseries) && + !( + // todo: we should provide an approach to handle derived metrics + ( + isTimeComparison(formData, queryObject) && + [ + ComparisionType.Difference, + ComparisionType.Ratio, + ComparisionType.Percentage, + ].includes(formData.comparison_type) + ) + ) + ) { + const renamePairs: [string, string | null][] = []; + + if ( + // "actual values" will add derived metric. + // we will rename the "metric" from the metricWithOffset label + // for example: "count__1 year ago" => "1 year ago" + isTimeComparison(formData, queryObject) && + formData.comparison_type === ComparisionType.Values + ) { + const metricOffsetMap = getMetricOffsetsMap(formData, queryObject); + const timeOffsets = ensureIsArray(formData.time_compare); + [...metricOffsetMap.keys()].forEach(metricWithOffset => { + const offsetLabel = timeOffsets.find(offset => + metricWithOffset.includes(offset), + ); + renamePairs.push([metricWithOffset, offsetLabel]); + }); + } + + renamePairs.push([getMetricLabel(metrics[0]), null]); + + return { + operation: 'rename', + options: { + columns: Object.fromEntries(renamePairs), + level: 0, + inplace: true, + }, + }; + } + + return undefined; +}; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/rollingWindowOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/rollingWindowOperator.ts index 563b3e0544faa..0ab459e5cae03 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/rollingWindowOperator.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/rollingWindowOperator.ts @@ -24,14 +24,14 @@ import { PostProcessingRolling, RollingType, } from '@superset-ui/core'; -import { getMetricOffsetsMap, isValidTimeCompare } from './utils'; +import { getMetricOffsetsMap, isTimeComparison } from './utils'; import { PostProcessingFactory } from './types'; export const rollingWindowOperator: PostProcessingFactory< PostProcessingRolling | PostProcessingCum > = (formData, queryObject) => { let columns: (string | undefined)[]; - if (isValidTimeCompare(formData, queryObject)) { + if (isTimeComparison(formData, queryObject)) { const metricsMap = getMetricOffsetsMap(formData, queryObject); columns = [ ...Array.from(metricsMap.values()), diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/timeCompareOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/timeCompareOperator.ts index ec62384615f74..3fe253edfdfd1 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/timeCompareOperator.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/timeCompareOperator.ts @@ -18,7 +18,7 @@ * under the License. */ import { ComparisionType, PostProcessingCompare } from '@superset-ui/core'; -import { getMetricOffsetsMap, isValidTimeCompare } from './utils'; +import { getMetricOffsetsMap, isTimeComparison } from './utils'; import { PostProcessingFactory } from './types'; export const timeCompareOperator: PostProcessingFactory = @@ -27,7 +27,7 @@ export const timeCompareOperator: PostProcessingFactory = const metricOffsetMap = getMetricOffsetsMap(formData, queryObject); if ( - isValidTimeCompare(formData, queryObject) && + isTimeComparison(formData, queryObject) && comparisonType !== ComparisionType.Values ) { return { diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/timeComparePivotOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/timeComparePivotOperator.ts index 44a1825ff8ee5..f7bbd238c6f54 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/timeComparePivotOperator.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/timeComparePivotOperator.ts @@ -24,14 +24,14 @@ import { NumpyFunction, PostProcessingPivot, } from '@superset-ui/core'; -import { getMetricOffsetsMap, isValidTimeCompare } from './utils'; +import { getMetricOffsetsMap, isTimeComparison } from './utils'; import { PostProcessingFactory } from './types'; export const timeComparePivotOperator: PostProcessingFactory = (formData, queryObject) => { const metricOffsetMap = getMetricOffsetsMap(formData, queryObject); - if (isValidTimeCompare(formData, queryObject)) { + if (isTimeComparison(formData, queryObject)) { const aggregates = Object.fromEntries( [...metricOffsetMap.values(), ...metricOffsetMap.keys()].map(metric => [ metric, diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/index.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/index.ts index d591dbd23edde..e4dfbd776908d 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/index.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/index.ts @@ -18,5 +18,5 @@ * under the License. */ export { getMetricOffsetsMap } from './getMetricOffsetsMap'; -export { isValidTimeCompare } from './isValidTimeCompare'; +export { isTimeComparison } from './isTimeComparison'; export { TIME_COMPARISON_SEPARATOR } from './constants'; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/isValidTimeCompare.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/isTimeComparison.ts similarity index 94% rename from superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/isValidTimeCompare.ts rename to superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/isTimeComparison.ts index 793bb392315d8..4430b9541cdbb 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/isValidTimeCompare.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/isTimeComparison.ts @@ -21,7 +21,7 @@ import { ComparisionType } from '@superset-ui/core'; import { getMetricOffsetsMap } from './getMetricOffsetsMap'; import { PostProcessingFactory } from '../types'; -export const isValidTimeCompare: PostProcessingFactory = ( +export const isTimeComparison: PostProcessingFactory = ( formData, queryObject, ) => { diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/sections/advancedAnalytics.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/sections/advancedAnalytics.tsx index ebd118d88122c..3d562309ca948 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/sections/advancedAnalytics.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/sections/advancedAnalytics.tsx @@ -30,7 +30,7 @@ export const advancedAnalyticsControls: ControlPanelSectionConfig = { 'of query results', ), controlSetRows: [ - [

{t('Rolling window')}

], + [
{t('Rolling window')}
], [ { name: 'rolling_type', @@ -85,7 +85,7 @@ export const advancedAnalyticsControls: ControlPanelSectionConfig = { }, }, ], - [

{t('Time comparison')}

], + [
{t('Time comparison')}
], [ { name: 'time_compare', @@ -136,7 +136,7 @@ export const advancedAnalyticsControls: ControlPanelSectionConfig = { }, }, ], - [

{t('Resample')}

], + [
{t('Resample')}
], [ { name: 'resample_rule', 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 5e99d976c55b3..314e983c589ae 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 @@ -28,7 +28,7 @@ export const titleControls: ControlPanelSectionConfig = { tabOverride: 'customize', expanded: true, controlSetRows: [ - [

{t('X Axis')}

], + [
{t('X Axis')}
], [ { name: 'x_axis_title', @@ -56,7 +56,7 @@ export const titleControls: ControlPanelSectionConfig = { }, }, ], - [

{t('Y Axis')}

], + [
{t('Y Axis')}
], [ { name: 'y_axis_title', diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/components/RadioButtonControl.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/components/RadioButtonControl.tsx index e9f6a6f9bc4d5..b613fed93aa87 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/components/RadioButtonControl.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/components/RadioButtonControl.tsx @@ -56,8 +56,11 @@ export default function RadioButtonControl({ '.control-label + .btn-group': { marginTop: 1, }, + '.btn-group .btn-default': { + color: theme.colors.grayscale.dark1, + }, '.btn-group .btn.active': { - background: theme.colors.secondary.light5, + background: theme.colors.grayscale.light4, fontWeight: theme.typography.weights.bold, boxShadow: 'none', }, diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/dndControls.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/dndControls.tsx index 44b4bcc186dda..44e0d2fb6381c 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/dndControls.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/dndControls.tsx @@ -28,7 +28,7 @@ import { TIME_COLUMN_OPTION, TIME_FILTER_LABELS } from '../constants'; export const dndGroupByControl: SharedControlConfig<'DndColumnSelect'> = { type: 'DndColumnSelect', - label: t('Group by'), + label: t('Dimensions'), default: [], description: t( 'One or many columns to group by. High cardinality groupings should include a series limit ' + @@ -58,7 +58,7 @@ export const dndColumnsControl: typeof dndGroupByControl = { export const dndSeries: typeof dndGroupByControl = { ...dndGroupByControl, - label: t('Series'), + label: t('Dimensions'), multi: false, default: null, description: t( diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/emitFilterControl.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/emitFilterControl.tsx index 5088ad155567e..a4c3f4a86d8af 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/emitFilterControl.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/emitFilterControl.tsx @@ -27,10 +27,10 @@ export const emitFilterControl = enableCrossFilter name: 'emit_filter', config: { type: 'CheckboxControl', - label: t('Emit dashboard cross filters'), + label: t('Enable dashboard cross filters'), default: false, renderTrigger: true, - description: t('Emit dashboard cross filters.'), + description: t('Enable dashboard cross filters'), }, }, ] diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.tsx index edd6e30b02fc8..382fcb5bb3316 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.tsx @@ -34,6 +34,7 @@ * control interface. */ import React from 'react'; +import { isEmpty } from 'lodash'; import { FeatureFlag, t, @@ -43,7 +44,6 @@ import { SequentialScheme, legacyValidateInteger, validateNonEmpty, - JsonArray, ComparisionType, } from '@superset-ui/core'; @@ -103,7 +103,7 @@ type SelectDefaultOption = { const groupByControl: SharedControlConfig<'SelectControl', ColumnMeta> = { type: 'SelectControl', - label: t('Group by'), + label: t('Dimensions'), multi: true, freeForm: true, clearable: true, @@ -352,7 +352,7 @@ const order_desc: SharedControlConfig<'CheckboxControl'> = { visibility: ({ controls }) => Boolean( controls?.timeseries_limit_metric.value && - (controls?.timeseries_limit_metric.value as JsonArray).length, + !isEmpty(controls?.timeseries_limit_metric.value), ), }; @@ -403,7 +403,7 @@ const sort_by: SharedControlConfig<'MetricsControl'> = { const series: typeof groupByControl = { ...groupByControl, - label: t('Series'), + label: t('Dimensions'), multi: false, default: null, description: t( diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/components/ColumnOption.test.tsx b/superset-frontend/packages/superset-ui-chart-controls/test/components/ColumnOption.test.tsx index cc0106e9d650c..b1fb4b26535bf 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/components/ColumnOption.test.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/test/components/ColumnOption.test.tsx @@ -20,12 +20,8 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; import { GenericDataType } from '@superset-ui/core'; -import { - ColumnOption, - ColumnOptionProps, - ColumnTypeLabel, - InfoTooltipWithTrigger, -} from '../../src'; +import { ColumnOption, ColumnOptionProps, ColumnTypeLabel } from '../../src'; +import { SQLPopover } from '../../src/components/SQLPopover'; describe('ColumnOption', () => { const defaultProps: ColumnOptionProps = { @@ -53,8 +49,8 @@ describe('ColumnOption', () => { expect(lbl).toHaveLength(1); expect(lbl.first().text()).toBe('Foo'); }); - it('shows 1 InfoTooltipWithTrigger', () => { - expect(wrapper.find(InfoTooltipWithTrigger)).toHaveLength(1); + it('shows SQL Popover trigger', () => { + expect(wrapper.find(SQLPopover)).toHaveLength(1); }); it('shows a label with column_name when no verbose_name', () => { delete props.column.verbose_name; diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/components/MetricOption.test.tsx b/superset-frontend/packages/superset-ui-chart-controls/test/components/MetricOption.test.tsx index e71882bd3ee14..59ba64c7bfe6f 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/components/MetricOption.test.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/test/components/MetricOption.test.tsx @@ -51,18 +51,21 @@ describe('MetricOption', () => { expect(lbl).toHaveLength(1); expect(lbl.first().text()).toBe('Foo'); }); - it('shows 2 InfoTooltipWithTrigger', () => { - expect(wrapper.find('InfoTooltipWithTrigger')).toHaveLength(2); + it('shows a InfoTooltipWithTrigger', () => { + expect(wrapper.find('InfoTooltipWithTrigger')).toHaveLength(1); + }); + it('shows SQL Popover trigger', () => { + expect(wrapper.find('SQLPopover')).toHaveLength(1); }); it('shows a label with metric_name when no verbose_name', () => { props.metric.verbose_name = ''; wrapper = shallow(factory(props)); expect(wrapper.find('.option-label').first().text()).toBe('foo'); }); - it('shows only 1 InfoTooltipWithTrigger when no warning', () => { + it('doesnt show InfoTooltipWithTrigger when no warning', () => { props.metric.warning_text = ''; wrapper = shallow(factory(props)); - expect(wrapper.find('InfoTooltipWithTrigger')).toHaveLength(1); + expect(wrapper.find('InfoTooltipWithTrigger')).toHaveLength(0); }); it('sets target="_blank" when openInNewWindow is true', () => { props.url = 'https://github.com/apache/incubator-superset'; diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/utils/operators/renameOperator.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/utils/operators/renameOperator.test.ts new file mode 100644 index 0000000000000..2c32e0791ba17 --- /dev/null +++ b/superset-frontend/packages/superset-ui-chart-controls/test/utils/operators/renameOperator.test.ts @@ -0,0 +1,146 @@ +/** + * 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 { ComparisionType, QueryObject, SqlaFormData } from '@superset-ui/core'; +import { renameOperator } from '@superset-ui/chart-controls'; + +const formData: SqlaFormData = { + x_axis: 'dttm', + metrics: ['count(*)'], + groupby: ['gender'], + time_range: '2015 : 2016', + granularity: 'month', + datasource: 'foo', + viz_type: 'table', +}; +const queryObject: QueryObject = { + is_timeseries: true, + metrics: ['count(*)'], + columns: ['gender', 'dttm'], + time_range: '2015 : 2016', + granularity: 'month', + post_processing: [], +}; + +test('should skip renameOperator if exists multiple metrics', () => { + expect( + renameOperator(formData, { + ...queryObject, + ...{ + metrics: ['count(*)', 'sum(sales)'], + }, + }), + ).toEqual(undefined); +}); + +test('should skip renameOperator if does not exist series', () => { + expect( + renameOperator(formData, { + ...queryObject, + ...{ + columns: [], + }, + }), + ).toEqual(undefined); +}); + +test('should skip renameOperator if does not exist x_axis and is_timeseries', () => { + expect( + renameOperator( + { + ...formData, + ...{ x_axis: null }, + }, + { ...queryObject, ...{ is_timeseries: false } }, + ), + ).toEqual(undefined); +}); + +test('should skip renameOperator if exists derived metrics', () => { + [ + ComparisionType.Difference, + ComparisionType.Ratio, + ComparisionType.Percentage, + ].forEach(type => { + expect( + renameOperator( + { + ...formData, + ...{ + comparison_type: type, + time_compare: ['1 year ago'], + }, + }, + { + ...queryObject, + ...{ + metrics: ['count(*)'], + }, + }, + ), + ).toEqual(undefined); + }); +}); + +test('should add renameOperator', () => { + expect(renameOperator(formData, queryObject)).toEqual({ + operation: 'rename', + options: { columns: { 'count(*)': null }, inplace: true, level: 0 }, + }); +}); + +test('should add renameOperator if does not exist x_axis', () => { + expect( + renameOperator( + { + ...formData, + ...{ x_axis: null }, + }, + queryObject, + ), + ).toEqual({ + operation: 'rename', + options: { columns: { 'count(*)': null }, inplace: true, level: 0 }, + }); +}); + +test('should add renameOperator if exist "actual value" time comparison', () => { + expect( + renameOperator( + { + ...formData, + ...{ + comparison_type: ComparisionType.Values, + time_compare: ['1 year ago', '1 year later'], + }, + }, + queryObject, + ), + ).toEqual({ + operation: 'rename', + options: { + columns: { + 'count(*)': null, + 'count(*)__1 year ago': '1 year ago', + 'count(*)__1 year later': '1 year later', + }, + inplace: true, + level: 0, + }, + }); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/color/CategoricalColorScale.ts b/superset-frontend/packages/superset-ui-core/src/color/CategoricalColorScale.ts index d34960dac0973..c6f37e4ff771f 100644 --- a/superset-frontend/packages/superset-ui-core/src/color/CategoricalColorScale.ts +++ b/superset-frontend/packages/superset-ui-core/src/color/CategoricalColorScale.ts @@ -23,6 +23,7 @@ import { ExtensibleFunction } from '../models'; import { ColorsLookup } from './types'; import stringifyAndTrim from './stringifyAndTrim'; import getSharedLabelColor from './SharedLabelColorSingleton'; +import { getAnalogousColors } from './utils'; // Use type augmentation to correct the fact that // an instance of CategoricalScale is also a function @@ -31,6 +32,8 @@ interface CategoricalColorScale { } class CategoricalColorScale extends ExtensibleFunction { + originColors: string[]; + colors: string[]; scale: ScaleOrdinal<{ toString(): string }, string>; @@ -39,6 +42,8 @@ class CategoricalColorScale extends ExtensibleFunction { forcedColors: ColorsLookup; + multiple: number; + /** * Constructor * @param {*} colors an array of colors @@ -48,11 +53,13 @@ class CategoricalColorScale extends ExtensibleFunction { constructor(colors: string[], parentForcedColors?: ColorsLookup) { super((value: string, sliceId?: number) => this.getColor(value, sliceId)); + this.originColors = colors; this.colors = colors; this.scale = scaleOrdinal<{ toString(): string }, string>(); this.scale.range(colors); this.parentForcedColors = parentForcedColors; this.forcedColors = {}; + this.multiple = 0; } getColor(value?: string, sliceId?: number) { @@ -72,6 +79,15 @@ class CategoricalColorScale extends ExtensibleFunction { return forcedColor; } + const multiple = Math.floor( + this.domain().length / this.originColors.length, + ); + if (multiple > this.multiple) { + this.multiple = multiple; + const newRange = getAnalogousColors(this.originColors, multiple); + this.range(this.originColors.concat(newRange)); + } + const color = this.scale(cleanedValue); sharedLabelColor.addSlice(cleanedValue, color, sliceId); diff --git a/superset-frontend/packages/superset-ui-core/src/color/SharedLabelColorSingleton.ts b/superset-frontend/packages/superset-ui-core/src/color/SharedLabelColorSingleton.ts index 227b565276a94..d2a59ac7c292a 100644 --- a/superset-frontend/packages/superset-ui-core/src/color/SharedLabelColorSingleton.ts +++ b/superset-frontend/packages/superset-ui-core/src/color/SharedLabelColorSingleton.ts @@ -17,9 +17,9 @@ * under the License. */ -import tinycolor from 'tinycolor2'; import { CategoricalColorNamespace } from '.'; import makeSingleton from '../utils/makeSingleton'; +import { getAnalogousColors } from './utils'; export class SharedLabelColor { sliceLabelColorMap: Record>; @@ -39,27 +39,16 @@ export class SharedLabelColor { CategoricalColorNamespace.getNamespace(colorNamespace); const colors = categoricalNamespace.getScale(colorScheme).range(); const sharedLabels = this.getSharedLabels(); - const generatedColors: tinycolor.Instance[] = []; + let generatedColors: string[] = []; let sharedLabelMap; if (sharedLabels.length) { const multiple = Math.ceil(sharedLabels.length / colors.length); - const ext = 5; - const analogousColors = colors.map(color => { - const result = tinycolor(color).analogous(multiple + ext); - return result.slice(ext); - }); - - // [[A, AA, AAA], [B, BB, BBB]] => [A, B, AA, BB, AAA, BBB] - while (analogousColors[analogousColors.length - 1]?.length) { - analogousColors.forEach(colors => - generatedColors.push(colors.shift() as tinycolor.Instance), - ); - } + generatedColors = getAnalogousColors(colors, multiple); sharedLabelMap = sharedLabels.reduce( (res, label, index) => ({ ...res, - [label.toString()]: generatedColors[index]?.toHexString(), + [label.toString()]: generatedColors[index], }), {}, ); diff --git a/superset-frontend/packages/superset-ui-core/src/color/utils.ts b/superset-frontend/packages/superset-ui-core/src/color/utils.ts index 47a936aaa6187..1b362efe3e100 100644 --- a/superset-frontend/packages/superset-ui-core/src/color/utils.ts +++ b/superset-frontend/packages/superset-ui-core/src/color/utils.ts @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import tinycolor from 'tinycolor2'; const rgbRegex = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/; export function getContrastingColor(color: string, thresholds = 186) { @@ -51,3 +52,37 @@ export function getContrastingColor(color: string, thresholds = 186) { return r * 0.299 + g * 0.587 + b * 0.114 > thresholds ? '#000' : '#FFF'; } + +export function getAnalogousColors(colors: string[], results: number) { + const generatedColors: string[] = []; + // This is to solve the problem that the first three values generated by tinycolor.analogous + // may have the same or very close colors. + const ext = 3; + const analogousColors = colors.map(color => { + const result = tinycolor(color).analogous(results + ext); + return result.slice(ext); + }); + + // [[A, AA, AAA], [B, BB, BBB]] => [A, B, AA, BB, AAA, BBB] + while (analogousColors[analogousColors.length - 1]?.length) { + analogousColors.forEach(colors => { + const color = colors.shift() as tinycolor.Instance; + generatedColors.push(color.toHexString()); + }); + } + + return generatedColors; +} + +export function addAlpha(color: string, opacity: number): string { + // opacity value should be between 0 and 1. + if (opacity > 1 || opacity < 0) { + throw new Error(`The opacity should between 0 and 1, but got: ${opacity}`); + } + // the alpha value is between 00 - FF + const alpha = `0${Math.round(opacity * 255) + .toString(16) + .toUpperCase()}`.slice(-2); + + return `${color}${alpha}`; +} diff --git a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts index bf0c02a81d789..7a6dfd97b0207 100644 --- a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts +++ b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts @@ -34,9 +34,9 @@ import { import { DEFAULT_FETCH_RETRY_OPTIONS, DEFAULT_BASE_URL } from './constants'; const defaultUnauthorizedHandler = () => { - window.location.href = `/login?next=${ - window.location.pathname + window.location.search - }`; + if (!window.location.pathname.startsWith('/login')) { + window.location.href = `/login?next=${window.location.href}`; + } }; export default class SupersetClientClass { @@ -161,7 +161,7 @@ export default class SupersetClientClass { headers, timeout, fetchRetryOptions, - ignoreUnauthorized, + ignoreUnauthorized = false, ...rest }: RequestConfig & { parseMethod?: T }) { await this.ensureAuth(); diff --git a/superset-frontend/packages/superset-ui-core/src/index.ts b/superset-frontend/packages/superset-ui-core/src/index.ts index 23d972fc9a051..2a53112f6fe2a 100644 --- a/superset-frontend/packages/superset-ui-core/src/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/index.ts @@ -35,3 +35,4 @@ export * from './chart'; export * from './chart-composition'; export * from './components'; export * from './math-expression'; +export * from './ui-overrides'; diff --git a/superset-frontend/packages/superset-ui-core/src/models/Registry.ts b/superset-frontend/packages/superset-ui-core/src/models/Registry.ts index 7bf7f175cb8c9..90a8065e29ac2 100644 --- a/superset-frontend/packages/superset-ui-core/src/models/Registry.ts +++ b/superset-frontend/packages/superset-ui-core/src/models/Registry.ts @@ -59,6 +59,10 @@ export interface RegistryConfig { /** * Registry class * + * !!!!!!!! + * IF YOU ARE ADDING A NEW REGISTRY TO SUPERSET, CONSIDER USING TypedRegistry + * !!!!!!!! + * * Can use generic to specify type of item in the registry * @type V Type of value * @type W Type of value returned from loader function when using registerLoader(). diff --git a/superset-frontend/packages/superset-ui-core/src/models/TypedRegistry.ts b/superset-frontend/packages/superset-ui-core/src/models/TypedRegistry.ts new file mode 100644 index 0000000000000..80e32167f560c --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/models/TypedRegistry.ts @@ -0,0 +1,60 @@ +/** + * 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. + */ + +/** + * A Registry which serves as a typed key:value store for Superset and for Plugins. + * + * Differences from the older Registry class: + * + * 1. The keys and values stored in this class are individually typed by TYPEMAP parameter. + * In the old Registry, all values are of the same type and keys are not enumerated. + * Though you can also use indexed or mapped types in a TYPEMAP. + * + * 2. This class does not have a separate async get and set methods or use loaders. + * Instead, TYPEMAP should specify async values and loaders explicitly when needed. + * The value can be anything! A string, a class, a function, an async function... anything! + * + * 3. This class does not implement Policies, that is a separate concern to be handled elsewhere. + * + * + * Removing or altering types in a type map could be a potential breaking change, be careful! + * + * Listener methods have not been added because there isn't a use case yet. + */ +class TypedRegistry { + name = 'TypedRegistry'; + + private records: TYPEMAP; + + constructor(initialRecords: TYPEMAP) { + this.records = initialRecords; + } + + get(key: K): TYPEMAP[K] { + // The type construction above means that when you call this function, + // you get a really specific type back. + return this.records[key]; + } + + set(key: K, value: TYPEMAP[K]) { + this.records[key] = value; + } +} + +export default TypedRegistry; diff --git a/superset-frontend/packages/superset-ui-core/src/models/index.ts b/superset-frontend/packages/superset-ui-core/src/models/index.ts index 10d46c2a7e5a8..365ed391d3319 100644 --- a/superset-frontend/packages/superset-ui-core/src/models/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/models/index.ts @@ -21,3 +21,4 @@ export { default as Plugin } from './Plugin'; export { default as Preset } from './Preset'; export { default as Registry, OverwritePolicy } from './Registry'; export { default as RegistryWithDefaultKey } from './RegistryWithDefaultKey'; +export { default as TypedRegistry } from './TypedRegistry'; 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 7e5ce853585ab..315cdb8456cda 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 @@ -201,10 +201,23 @@ export type PostProcessingResample = | _PostProcessingResample | DefaultPostProcessing; +interface _PostProcessingRename { + operation: 'rename'; + options: { + columns: Record; + inplace?: boolean; + level?: number | string; + }; +} +export type PostProcessingRename = + | _PostProcessingRename + | DefaultPostProcessing; + interface _PostProcessingFlatten { operation: 'flatten'; options?: { reset_index?: boolean; + drop_levels?: number[] | string[]; }; } export type PostProcessingFlatten = @@ -227,6 +240,7 @@ export type PostProcessingRule = | PostProcessingCompare | PostProcessingSort | PostProcessingResample + | PostProcessingRename | PostProcessingFlatten; export function isPostProcessingAggregation( diff --git a/superset-frontend/packages/superset-ui-core/src/ui-overrides/UiOverrideRegistry.ts b/superset-frontend/packages/superset-ui-core/src/ui-overrides/UiOverrideRegistry.ts new file mode 100644 index 0000000000000..fb74ae1ece493 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/ui-overrides/UiOverrideRegistry.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 React from 'react'; +import { TypedRegistry } from '../models'; +import { makeSingleton } from '../utils'; + +/** A function (or component) which returns text (or marked-up text) */ +type UiGeneratorText

= (props: P) => string | React.ReactElement; + +/** + * This type defines all the UI override options which replace elements of Superset's default UI. + * Idea with the keys here is generally to namespace following the form of 'domain.functonality.item' + * + * When defining a new option here, take care to keep any parameters to functions (or components) minimal. + * Any removal or alteration to a parameter will be considered a breaking change. + */ +export type UiOverrides = Partial<{ + 'embedded.documentation.description': UiGeneratorText; + 'embedded.documentation.url': string; +}>; + +/** + * A registry containing UI customizations to replace elements of Superset's default UI. + */ +class UiOverrideRegistry extends TypedRegistry { + name = 'UiOverrideRegistry'; +} + +export const getUiOverrideRegistry = makeSingleton(UiOverrideRegistry, {}); diff --git a/superset-frontend/src/visualizations/TimeTable/TimeTable.less b/superset-frontend/packages/superset-ui-core/src/ui-overrides/index.tsx similarity index 95% rename from superset-frontend/src/visualizations/TimeTable/TimeTable.less rename to superset-frontend/packages/superset-ui-core/src/ui-overrides/index.tsx index 6ebbedce7b35e..d59afc216fb8b 100644 --- a/superset-frontend/src/visualizations/TimeTable/TimeTable.less +++ b/superset-frontend/packages/superset-ui-core/src/ui-overrides/index.tsx @@ -16,6 +16,5 @@ * specific language governing permissions and limitations * under the License. */ -.time-table { - overflow: auto; -} + +export * from './UiOverrideRegistry'; diff --git a/superset-frontend/packages/superset-ui-core/test/color/CategoricalColorScale.test.ts b/superset-frontend/packages/superset-ui-core/test/color/CategoricalColorScale.test.ts index f080b6fc84e5d..1d47cf760e326 100644 --- a/superset-frontend/packages/superset-ui-core/test/color/CategoricalColorScale.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/color/CategoricalColorScale.test.ts @@ -62,28 +62,15 @@ describe('CategoricalColorScale', () => { expect(c2).not.toBe(c3); expect(c3).not.toBe(c1); }); - it('recycles colors when number of items exceed available colors', () => { - const colorSet: { [key: string]: number } = {}; + it('get analogous colors when number of items exceed available colors', () => { const scale = new CategoricalColorScale(['blue', 'red', 'green']); - const colors = [ - scale.getColor('pig'), - scale.getColor('horse'), - scale.getColor('cat'), - scale.getColor('cow'), - scale.getColor('donkey'), - scale.getColor('goat'), - ]; - colors.forEach(color => { - if (colorSet[color]) { - colorSet[color] += 1; - } else { - colorSet[color] = 1; - } - }); - expect(Object.keys(colorSet)).toHaveLength(3); - ['blue', 'red', 'green'].forEach(color => { - expect(colorSet[color]).toBe(2); - }); + scale.getColor('pig'); + scale.getColor('horse'); + scale.getColor('cat'); + scale.getColor('cow'); + scale.getColor('donkey'); + scale.getColor('goat'); + expect(scale.range()).toHaveLength(6); }); }); describe('.setColor(value, forcedColor)', () => { diff --git a/superset-frontend/packages/superset-ui-core/test/color/utils.test.ts b/superset-frontend/packages/superset-ui-core/test/color/utils.test.ts index f04b88dafc70e..308eec726b73a 100644 --- a/superset-frontend/packages/superset-ui-core/test/color/utils.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/color/utils.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { getContrastingColor } from '@superset-ui/core'; +import { getContrastingColor, addAlpha } from '@superset-ui/core'; describe('color utils', () => { describe('getContrastingColor', () => { @@ -60,4 +60,26 @@ describe('color utils', () => { }).toThrow(); }); }); + describe('addAlpha', () => { + it('adds 20% opacity to black', () => { + expect(addAlpha('#000000', 0.2)).toBe('#00000033'); + }); + it('adds 50% opacity to white', () => { + expect(addAlpha('#FFFFFF', 0.5)).toBe('#FFFFFF80'); + }); + it('should apply transparent alpha', () => { + expect(addAlpha('#000000', 0)).toBe('#00000000'); + }); + it('should apply fully opaque', () => { + expect(addAlpha('#000000', 1)).toBe('#000000FF'); + }); + it('opacity should be between 0 and 1', () => { + expect(() => { + addAlpha('#000000', 2); + }).toThrow(); + expect(() => { + addAlpha('#000000', -1); + }).toThrow(); + }); + }); }); diff --git a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts index 921061b22f297..ef31e5d35d857 100644 --- a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts @@ -505,7 +505,7 @@ describe('SupersetClientClass', () => { const mockRequestUrl = 'https://host/get/url'; const mockRequestPath = '/get/url'; const mockRequestSearch = '?param=1¶m=2'; - const mockHref = `http://localhost${mockRequestPath + mockRequestSearch}`; + const mockHref = mockRequestUrl + mockRequestSearch; beforeEach(() => { originalLocation = window.location; @@ -541,13 +541,31 @@ describe('SupersetClientClass', () => { error = err; } finally { const redirectURL = window.location.href; - expect(redirectURL).toBe( - `/login?next=${mockRequestPath + mockRequestSearch}`, - ); + expect(redirectURL).toBe(`/login?next=${mockHref}`); expect(error.status).toBe(401); } }); + it('should not redirect again if already on login page', async () => { + const client = new SupersetClientClass({}); + + // @ts-expect-error + window.location = { + href: '/login?next=something', + pathname: '/login', + search: '?next=something', + }; + + let error; + try { + await client.request({ url: mockRequestUrl, method: 'GET' }); + } catch (err) { + error = err; + } finally { + expect(window.location.href).toBe('/login?next=something'); + expect(error.status).toBe(401); + } + }); it('does nothing if instructed to ignoreUnauthorized', async () => { const client = new SupersetClientClass({}); diff --git a/superset-frontend/packages/superset-ui-core/test/models/ExtensibleFunction.test.ts b/superset-frontend/packages/superset-ui-core/test/models/ExtensibleFunction.test.ts index 3f8c38e1ae568..931989853d668 100644 --- a/superset-frontend/packages/superset-ui-core/test/models/ExtensibleFunction.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/models/ExtensibleFunction.test.ts @@ -55,7 +55,7 @@ describe('ExtensibleFunction', () => { // @ts-ignore super(function customName() { // @ts-ignore - return customName.x as unknown; + return customName.x; }); // named function this.x = x; } diff --git a/superset-frontend/plugins/legacy-plugin-chart-treemap/src/Treemap.css b/superset-frontend/packages/superset-ui-core/test/models/TypedRegistry.test.ts similarity index 68% rename from superset-frontend/plugins/legacy-plugin-chart-treemap/src/Treemap.css rename to superset-frontend/packages/superset-ui-core/test/models/TypedRegistry.test.ts index f49b425f200ba..7eb0d88f9d7eb 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-treemap/src/Treemap.css +++ b/superset-frontend/packages/superset-ui-core/test/models/TypedRegistry.test.ts @@ -1,4 +1,4 @@ -/** +/* * 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 @@ -17,20 +17,17 @@ * under the License. */ -.superset-legacy-chart-treemap text { - font-size: 11px; - pointer-events: none; -} +import { TypedRegistry } from '@superset-ui/core'; -.superset-legacy-chart-treemap tspan:last-child { - font-size: 9px; - fill-opacity: 0.8; -} +describe('TypedRegistry', () => { + it('gets a value', () => { + const reg = new TypedRegistry({ foo: 'bar' }); + expect(reg.get('foo')).toBe('bar'); + }); -.superset-legacy-chart-treemap .node rect { - shape-rendering: crispEdges; -} - -.superset-legacy-chart-treemap .node--hover rect { - stroke: #000; -} + it('sets a value', () => { + const reg = new TypedRegistry({ foo: 'bar' }); + reg.set('foo', 'blah'); + expect(reg.get('foo')).toBe('blah'); + }); +}); diff --git a/superset-frontend/packages/superset-ui-core/test/query/buildQueryObject.test.ts b/superset-frontend/packages/superset-ui-core/test/query/buildQueryObject.test.ts index b8da644653fce..321e2a8401776 100644 --- a/superset-frontend/packages/superset-ui-core/test/query/buildQueryObject.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/query/buildQueryObject.test.ts @@ -285,7 +285,8 @@ describe('buildQueryObject', () => { datasource: '5__table', granularity_sqla: 'ds', viz_type: 'table', - url_params: null as unknown as undefined, + // @ts-expect-error + url_params: null, }).url_params, ).toBeUndefined(); }); diff --git a/superset-frontend/packages/superset-ui-demo/package.json b/superset-frontend/packages/superset-ui-demo/package.json index 2e86c92ae956c..bf3da61c12583 100644 --- a/superset-frontend/packages/superset-ui-demo/package.json +++ b/superset-frontend/packages/superset-ui-demo/package.json @@ -69,7 +69,6 @@ "@superset-ui/legacy-plugin-chart-chord": "*", "@superset-ui/legacy-plugin-chart-country-map": "*", "@superset-ui/legacy-plugin-chart-event-flow": "*", - "@superset-ui/legacy-plugin-chart-force-directed": "*", "@superset-ui/legacy-plugin-chart-heatmap": "*", "@superset-ui/legacy-plugin-chart-histogram": "*", "@superset-ui/legacy-plugin-chart-horizon": "*", diff --git a/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-force-directed/Stories.tsx b/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-force-directed/Stories.tsx deleted file mode 100644 index 10e8d761391a1..0000000000000 --- a/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-force-directed/Stories.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/* eslint-disable no-magic-numbers */ -import React from 'react'; -import { SuperChart } from '@superset-ui/core'; -import ForceDirectedChartPlugin from '@superset-ui/legacy-plugin-chart-force-directed'; -import data from './data'; - -new ForceDirectedChartPlugin().configure({ key: 'force-directed' }).register(); - -export default { - title: 'Legacy Chart Plugins/legacy-plugin-chart-force-directed', -}; - -export const basic = () => ( - -); diff --git a/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-force-directed/data.ts b/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-force-directed/data.ts deleted file mode 100644 index 9a06b22cd37ea..0000000000000 --- a/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-force-directed/data.ts +++ /dev/null @@ -1,447 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/* eslint-disable sort-keys */ -export default [ - { - source: 'Energy', - target: 'Electricity and heat', - value: 24.9, - }, - { - source: 'Energy', - target: 'Industry', - value: 14.7, - }, - { - source: 'Energy', - target: 'Transportation', - value: 14.3, - }, - { - source: 'Deforestation', - target: 'Carbon Dioxide', - value: 10.9, - }, - { - source: 'Land Use Change', - target: 'Deforestation', - value: 10.9, - }, - { - source: 'Road', - target: 'Carbon Dioxide', - value: 10.5, - }, - { - source: 'Transportation', - target: 'Road', - value: 10.5, - }, - { - source: 'Residential Buildings', - target: 'Carbon Dioxide', - value: 10.2, - }, - { - source: 'Energy', - target: 'Other Fuel Combustion', - value: 8.6, - }, - { - source: 'Other Industry', - target: 'Carbon Dioxide', - value: 6.6, - }, - { - source: 'Commercial Buildings', - target: 'Carbon Dioxide', - value: 6.3, - }, - { - source: 'Agriculture', - target: 'Livestock and Manure', - value: 5.4, - }, - { - source: 'Agriculture', - target: 'Agriculture Soils', - value: 5.2, - }, - { - source: 'Agriculture Soils', - target: 'Nitrous Oxide', - value: 5.2, - }, - { - source: 'Electricity and heat', - target: 'Residential Buildings', - value: 5.2, - }, - { - source: 'Livestock and Manure', - target: 'Methane', - value: 5.1, - }, - { - source: 'Cement', - target: 'Carbon Dioxide', - value: 5.0, - }, - { - source: 'Electricity and heat', - target: 'Commercial Buildings', - value: 5.0, - }, - { - source: 'Other Fuel Combustion', - target: 'Residential Buildings', - value: 5.0, - }, - { - source: 'Energy', - target: 'Fugitive Emissions', - value: 4.0, - }, - { - source: 'Iron and Steel', - target: 'Carbon Dioxide', - value: 4.0, - }, - { - source: 'Industry', - target: 'Other Industry', - value: 3.8, - }, - { - source: 'Oil and Gas Processing', - target: 'Carbon Dioxide', - value: 3.6, - }, - { - source: 'Chemicals', - target: 'Carbon Dioxide', - value: 3.4, - }, - { - source: 'Fugitive Emissions', - target: 'Oil and Gas Processing', - value: 3.2, - }, - { - source: 'Industry', - target: 'Iron and Steel', - value: 3.0, - }, - { - source: 'Unallocated Fuel Combustion', - target: 'Carbon Dioxide', - value: 3.0, - }, - { - source: 'Industrial Processes', - target: 'Cement', - value: 2.8, - }, - { - source: 'Industry', - target: 'Oil and Gas Processing', - value: 2.8, - }, - { - source: 'Oil and Gas Processing', - target: 'Methane', - value: 2.8, - }, - { - source: 'Electricity and heat', - target: 'Other Industry', - value: 2.7, - }, - { - source: 'Rail - Ship and Other Transport', - target: 'Carbon Dioxide', - value: 2.5, - }, - { - source: 'Transportation', - target: 'Rail - Ship and Other Transport', - value: 2.5, - }, - { - source: 'Electricity and heat', - target: 'T and D Losses', - value: 2.2, - }, - { - source: 'T and D Losses', - target: 'Carbon Dioxide', - value: 2.2, - }, - { - source: 'Electricity and heat', - target: 'Unallocated Fuel Combustion', - value: 2.0, - }, - { - source: 'Industry', - target: 'Cement', - value: 1.9, - }, - { - source: 'Other Fuel Combustion', - target: 'Unallocated Fuel Combustion', - value: 1.8, - }, - { - source: 'Agriculture', - target: 'Other Agriculture', - value: 1.7, - }, - { - source: 'Air', - target: 'Carbon Dioxide', - value: 1.7, - }, - { - source: 'Landfills', - target: 'Methane', - value: 1.7, - }, - { - source: 'Transportation', - target: 'Air', - value: 1.7, - }, - { - source: 'Waste', - target: 'Landfills', - value: 1.7, - }, - { - source: 'Agriculture', - target: 'Rice Cultivation', - value: 1.5, - }, - { - source: 'Rice Cultivation', - target: 'Methane', - value: 1.5, - }, - { - source: 'Waste', - target: 'Waste water - Other Waste', - value: 1.5, - }, - { - source: 'Agricultural Energy Use', - target: 'Carbon Dioxide', - value: 1.4, - }, - { - source: 'Industrial Processes', - target: 'Chemicals', - value: 1.4, - }, - { - source: 'Industry', - target: 'Chemicals', - value: 1.4, - }, - { - source: 'Other Agriculture', - target: 'Methane', - value: 1.4, - }, - { - source: 'Electricity and heat', - target: 'Chemicals', - value: 1.3, - }, - { - source: 'Fugitive Emissions', - target: 'Coal Mining', - value: 1.3, - }, - { - source: 'Harvest / Management', - target: 'Carbon Dioxide', - value: 1.3, - }, - { - source: 'Land Use Change', - target: 'Harvest / Management', - value: 1.3, - }, - { - source: 'Other Fuel Combustion', - target: 'Commercial Buildings', - value: 1.3, - }, - { - source: 'Coal Mining', - target: 'Methane', - value: 1.2, - }, - { - source: 'Waste water - Other Waste', - target: 'Methane', - value: 1.2, - }, - { - source: 'Pulp - Paper and Printing', - target: 'Carbon Dioxide', - value: 1.1, - }, - { - source: 'Aluminium Non-Ferrous Metals', - target: 'Carbon Dioxide', - value: 1.0, - }, - { - source: 'Electricity and heat', - target: 'Iron and Steel', - value: 1.0, - }, - { - source: 'Electricity and heat', - target: 'Machinery', - value: 1.0, - }, - { - source: 'Food and Tobacco', - target: 'Carbon Dioxide', - value: 1.0, - }, - { - source: 'Machinery', - target: 'Carbon Dioxide', - value: 1.0, - }, - { - source: 'Other Fuel Combustion', - target: 'Agricultural Energy Use', - value: 1.0, - }, - { - source: 'Electricity and heat', - target: 'Pulp - Paper and Printing', - value: 0.6, - }, - { - source: 'Chemicals', - target: 'HFCs - PFCs', - value: 0.5, - }, - { - source: 'Electricity and heat', - target: 'Food and Tobacco', - value: 0.5, - }, - { - source: 'Industrial Processes', - target: 'Other Industry', - value: 0.5, - }, - { - source: 'Industry', - target: 'Food and Tobacco', - value: 0.5, - }, - { - source: 'Industry', - target: 'Pulp - Paper and Printing', - value: 0.5, - }, - { - source: 'Electricity and heat', - target: 'Aluminium Non-Ferrous Metals', - value: 0.4, - }, - { - source: 'Electricity and heat', - target: 'Oil and Gas Processing', - value: 0.4, - }, - { - source: 'Electricity,heat', - target: 'Agricultural Energy Use', - value: 0.4, - }, - { - source: 'Industrial Processes', - target: 'Aluminium Non-Ferrous Metals', - value: 0.4, - }, - { - source: 'Industry', - target: 'Aluminium Non-Ferrous Metals', - value: 0.4, - }, - { - source: 'Other Industry', - target: 'HFCs - PFCs', - value: 0.4, - }, - { - source: 'Unallocated Fuel Combustion', - target: 'Methane', - value: 0.4, - }, - { - source: 'Unallocated Fuel Combustion', - target: 'Nitrous Oxide', - value: 0.4, - }, - { - source: 'Electricity and heat', - target: 'Cement', - value: 0.3, - }, - { - source: 'Livestock and Manure', - target: 'Nitrous Oxide', - value: 0.3, - }, - { - source: 'Other Agriculture', - target: 'Nitrous Oxide', - value: 0.3, - }, - { - source: 'Waste water - Other Waste', - target: 'Nitrous Oxide', - value: 0.3, - }, - { - source: 'Aluminium Non-Ferrous Metals', - target: 'HFCs - PFCs', - value: 0.2, - }, - { - source: 'Chemicals', - target: 'Nitrous Oxide', - value: 0.2, - }, - { - source: 'Coal Mining', - target: 'Carbon Dioxide', - value: 0.1, - }, -]; diff --git a/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-table/testData.ts b/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-table/testData.ts index 2a54c2a4da793..fd5245f691030 100644 --- a/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-table/testData.ts +++ b/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-table/testData.ts @@ -25,7 +25,7 @@ import { // eslint-disable-next-line import/extensions import birthNamesJson from './birthNames.json'; -export const birthNames = birthNamesJson as unknown as TableChartProps; +export const birthNames = birthNamesJson as TableChartProps; export const basicFormData: TableChartFormData = { datasource: '1__table', diff --git a/superset-frontend/plugins/legacy-plugin-chart-chord/src/ReactChord.jsx b/superset-frontend/plugins/legacy-plugin-chart-chord/src/ReactChord.jsx index 945f81428fa98..64c749d61ca3d 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-chord/src/ReactChord.jsx +++ b/superset-frontend/plugins/legacy-plugin-chart-chord/src/ReactChord.jsx @@ -39,18 +39,20 @@ Chord.propTypes = { }; export default styled(Chord)` - .superset-legacy-chart-chord svg #circle circle { - fill: none; - pointer-events: all; - } - .superset-legacy-chart-chord svg .group path { - fill-opacity: 0.6; - } - .superset-legacy-chart-chord svg path.chord { - stroke: #000; - stroke-width: 0.25px; - } - .superset-legacy-chart-chord svg #circle:hover path.fade { - opacity: 0.2; - } + ${({ theme }) => ` + .superset-legacy-chart-chord svg #circle circle { + fill: none; + pointer-events: all; + } + .superset-legacy-chart-chord svg .group path { + fill-opacity: ${theme.opacity.mediumHeavy}; + } + .superset-legacy-chart-chord svg path.chord { + stroke: ${theme.colors.grayscale.dark2}; + stroke-width: 0.25px; + } + .superset-legacy-chart-chord svg #circle:hover path.fade { + opacity: ${theme.opacity.light}; + } + `} `; diff --git a/superset-frontend/plugins/legacy-plugin-chart-country-map/package.json b/superset-frontend/plugins/legacy-plugin-chart-country-map/package.json index 4df451823250e..1b4ee339e8359 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-country-map/package.json +++ b/superset-frontend/plugins/legacy-plugin-chart-country-map/package.json @@ -2,9 +2,6 @@ "name": "@superset-ui/legacy-plugin-chart-country-map", "version": "0.18.25", "description": "Superset Legacy Chart - Country Map", - "sideEffects": [ - "*.css" - ], "main": "lib/index.js", "module": "esm/index.js", "files": [ @@ -34,6 +31,7 @@ }, "peerDependencies": { "@superset-ui/chart-controls": "*", - "@superset-ui/core": "*" + "@superset-ui/core": "*", + "react": "^16.13.1" } } diff --git a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/CountryMap.js b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/CountryMap.js index d363e8a5980b5..61ca6cc2fe76b 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/CountryMap.js +++ b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/CountryMap.js @@ -26,7 +26,6 @@ import { CategoricalColorNamespace, } from '@superset-ui/core'; import countries, { countryOptions } from './countries'; -import './CountryMap.css'; const propTypes = { data: PropTypes.arrayOf( diff --git a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/ReactCountryMap.js b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/ReactCountryMap.js deleted file mode 100644 index 40fc6e8347178..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/ReactCountryMap.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { reactify } from '@superset-ui/core'; -import Component from './CountryMap'; - -export default reactify(Component); diff --git a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/ReactCountryMap.jsx b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/ReactCountryMap.jsx new file mode 100644 index 0000000000000..f6e532aa46daa --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/ReactCountryMap.jsx @@ -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 React from 'react'; +import { reactify, styled } from '@superset-ui/core'; +import Component from './CountryMap'; + +const ReactComponent = reactify(Component); + +const CountryMap = ({ className, ...otherProps }) => ( +

+ +
+); + +export default styled(CountryMap)` + ${({ theme }) => ` + .superset-legacy-chart-country-map svg { + background-color: ${theme.colors.grayscale.light5}; + } + + .superset-legacy-chart-country-map { + position: relative; + } + + .superset-legacy-chart-country-map .background { + fill: ${theme.colors.grayscale.light5}; + pointer-events: all; + } + + .superset-legacy-chart-country-map .map-layer { + fill: ${theme.colors.grayscale.light5}; + stroke: ${theme.colors.grayscale.light1}; + } + + .superset-legacy-chart-country-map .effect-layer { + pointer-events: none; + } + + .superset-legacy-chart-country-map .text-layer { + color: ${theme.colors.grayscale.dark1}; + text-anchor: middle; + pointer-events: none; + } + + .superset-legacy-chart-country-map text.result-text { + font-weight: ${theme.typography.weights.light}; + font-size: ${theme.typography.sizes.xl}px; + } + + .superset-legacy-chart-country-map text.big-text { + font-weight: ${theme.typography.weights.bold}; + font-size: ${theme.typography.sizes.l}px; + } + + .superset-legacy-chart-country-map path.region { + cursor: pointer; + stroke: ${theme.colors.grayscale.light2}; + } + `} +`; diff --git a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/countries/ukraine.geojson b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/countries/ukraine.geojson index a4b3c47b99a2c..6d2c9151227c7 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/countries/ukraine.geojson +++ b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/countries/ukraine.geojson @@ -26,6 +26,7 @@ { "type": "Feature", "properties": { "ISO": "UA-12", "NAME_1": "Dnipropetrovs'k" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 34.983942499047089, 49.163717760166492 ], [ 35.169822625413815, 49.147232978419197 ], [ 35.361335483645632, 49.022020982382401 ], [ 35.462466262208238, 48.974892076208334 ], [ 35.579203321576017, 48.975822252195144 ], [ 35.794383986132061, 48.934222724199344 ], [ 35.930654737626014, 48.976390692976054 ], [ 35.952513868819949, 48.968690904461653 ], [ 36.022018670125533, 48.900116279142935 ], [ 35.961970655621258, 48.854899399987289 ], [ 36.072144809835095, 48.829112861069632 ], [ 36.163715447909567, 48.767256171435122 ], [ 36.170691766012169, 48.757747707790429 ], [ 36.132812941064344, 48.723692939577461 ], [ 36.285671827992473, 48.634111842887194 ], [ 36.267533399846513, 48.59780914997225 ], [ 36.307582635130473, 48.569903875763202 ], [ 36.27404463085503, 48.541042588944322 ], [ 36.284276564012202, 48.528252671823338 ], [ 36.374761997368239, 48.541636868146895 ], [ 36.364530064211067, 48.558457547577746 ], [ 36.382358433095192, 48.580549220969772 ], [ 36.477184686224291, 48.646875922485833 ], [ 36.583431430915766, 48.602666734582101 ], [ 36.725438267062373, 48.625921128856987 ], [ 36.738305697649821, 48.601581528964346 ], [ 36.787191603010058, 48.603700263356416 ], [ 36.749312778062233, 48.563961087334292 ], [ 36.751173130035909, 48.529234523754269 ], [ 36.795098097099469, 48.53481557877592 ], [ 36.81917931457366, 48.578249620323675 ], [ 36.883103061756856, 48.57930898751971 ], [ 36.891009555846324, 48.533988756475878 ], [ 36.854215935616935, 48.51652212189839 ], [ 36.858556756289261, 48.492673448420874 ], [ 36.835457390745944, 48.476343696304525 ], [ 36.847239618413653, 48.433969021053429 ], [ 36.808947381416488, 48.413143419533185 ], [ 36.81421837717636, 48.319014798394221 ], [ 36.901086460271927, 48.300307929467351 ], [ 36.913798862127749, 48.263824368499797 ], [ 36.927183057552043, 48.201502590871826 ], [ 36.869770542276683, 48.161039944437846 ], [ 36.892559848558165, 48.067557278445634 ], [ 36.829411248630208, 48.032107245353075 ], [ 36.665080194195582, 48.088072822502738 ], [ 36.589064161881538, 48.078926093164625 ], [ 36.57505984883295, 48.058229681954231 ], [ 36.57661014244411, 47.970715643711969 ], [ 36.61340376177418, 47.95702138902584 ], [ 36.63402265771947, 47.924258531106318 ], [ 36.534442172768024, 47.905034898241922 ], [ 36.53304690878781, 47.883640855041392 ], [ 36.569478793811243, 47.867362778869108 ], [ 36.576145053551386, 47.844211738281047 ], [ 36.39197024862807, 47.825711574929187 ], [ 36.361222772313056, 47.839870916709401 ], [ 36.285981887254252, 47.813309231435881 ], [ 36.214926792337565, 47.830310777120644 ], [ 36.203454623931748, 47.862763577577027 ], [ 36.15224328040307, 47.837907212847597 ], [ 36.078656039944292, 47.845167751790314 ], [ 36.060827671060167, 47.87377065709012 ], [ 36.098396436746157, 47.873357245040779 ], [ 36.117620069610496, 47.938676256204246 ], [ 36.059122348717381, 47.951130276540994 ], [ 36.053076205702325, 47.993039862899309 ], [ 36.021295199713677, 48.004227810465068 ], [ 36.044704623619452, 48.043295192918777 ], [ 35.974579705588894, 48.028644924723494 ], [ 35.959180128560035, 48.090708319933015 ], [ 35.808853387174054, 48.066833808033778 ], [ 35.674132928391884, 48.126985175325558 ], [ 35.514762811354615, 48.079391181158087 ], [ 35.254571974117312, 48.129672350498538 ], [ 35.164551628754623, 48.119130357180211 ], [ 35.118146192992413, 48.129052231974924 ], [ 35.017428827378467, 48.116649889380881 ], [ 34.943118117407153, 48.080993149813992 ], [ 34.886015658695669, 48.080889797925806 ], [ 34.869892612154331, 48.119337062755221 ], [ 34.851444125645855, 48.122747708340057 ], [ 34.835889519885427, 48.108588364761204 ], [ 34.840230339658433, 48.041305649735932 ], [ 34.813410271966461, 48.015519110818275 ], [ 34.842245721262998, 48.006553250432148 ], [ 34.857025180667506, 47.978389593804707 ], [ 34.90839155472645, 47.966684882301479 ], [ 34.916298048815861, 47.947151191074568 ], [ 34.819766473344032, 47.906740221484029 ], [ 34.808139276206589, 47.877543036082216 ], [ 34.901880323717933, 47.83082754195749 ], [ 34.886325717957504, 47.760134182246702 ], [ 34.945288526844024, 47.742770901355982 ], [ 34.895317416765351, 47.597250067736411 ], [ 34.929630568296091, 47.563298652310948 ], [ 34.898262974356783, 47.523249417026932 ], [ 34.594612258049267, 47.568621324014885 ], [ 34.445939162162574, 47.522887681821032 ], [ 34.157481317308907, 47.47389842457261 ], [ 33.901786330374819, 47.514464422894775 ], [ 33.654411248680162, 47.486817532003499 ], [ 33.624335565034301, 47.511622218990169 ], [ 33.584441359381231, 47.51472280531317 ], [ 33.575294630942437, 47.537589626859756 ], [ 33.588782180053556, 47.560043036357001 ], [ 33.562892287449074, 47.575365099020075 ], [ 33.494265985286972, 47.564151313032653 ], [ 33.485274285579806, 47.532732042249904 ], [ 33.365953402927175, 47.51436107010727 ], [ 33.339443393597719, 47.49508576129881 ], [ 33.283426141403311, 47.539320786724886 ], [ 32.994193150193723, 47.595441393505496 ], [ 33.02070315862386, 47.642596137201963 ], [ 32.983754509662845, 47.710008043436403 ], [ 33.086952345774137, 47.731712144999449 ], [ 33.060287305914414, 47.775585436118945 ], [ 33.0827665547327, 47.89593984574725 ], [ 33.031710239036272, 47.919736843280646 ], [ 33.057496778853249, 48.025802720818888 ], [ 33.134287956623893, 48.076962389302821 ], [ 33.2222929221806, 48.090449938413883 ], [ 33.241981642139081, 48.173907375924614 ], [ 33.268801710730372, 48.102723089798758 ], [ 33.348125034043051, 48.185947984212078 ], [ 33.40290205278751, 48.175250963061444 ], [ 33.470546502119362, 48.227986761779619 ], [ 33.513386265363863, 48.238502915776905 ], [ 33.525943637588796, 48.297414048719361 ], [ 33.500002069040249, 48.311211656192938 ], [ 33.488529900634376, 48.403945014251008 ], [ 33.471476678106171, 48.422961941540336 ], [ 33.484499139223885, 48.443916734269862 ], [ 33.461554803311515, 48.497763577027456 ], [ 33.443261346433985, 48.504274807136596 ], [ 33.4454317558708, 48.525307115131227 ], [ 33.580410597071364, 48.559413561086956 ], [ 33.821067743081869, 48.668399155996212 ], [ 33.769701369022926, 48.680543117970501 ], [ 33.756523879173642, 48.697699693286211 ], [ 33.598549025217324, 48.721832586704465 ], [ 33.557776320420828, 48.762295234037822 ], [ 33.572194044619437, 48.787074083502148 ], [ 33.619374626737624, 48.784231878698222 ], [ 33.664798210568961, 48.807615465081597 ], [ 33.70887820816273, 48.782500718833091 ], [ 33.760399610953243, 48.807098701144128 ], [ 33.774817336051171, 48.77898672136007 ], [ 33.801844110217473, 48.796143297575099 ], [ 33.810990837756947, 48.761985174775987 ], [ 33.82571862211671, 48.759453030133216 ], [ 33.919459669628054, 48.865803128511573 ], [ 34.040175816260899, 48.807822171555927 ], [ 34.161977165813539, 48.784386909228431 ], [ 34.237476434190057, 48.746275540283932 ], [ 34.293752068802917, 48.778314927791655 ], [ 34.275872023075408, 48.809992580992798 ], [ 34.315921258359367, 48.839603175745935 ], [ 34.308789910625876, 48.867353421223413 ], [ 34.274321730363567, 48.891227932223273 ], [ 34.277732375049027, 48.922285467800066 ], [ 34.308169793001525, 48.970964666685973 ], [ 34.367907749143285, 48.991066800492433 ], [ 34.34449832523751, 49.008533434170602 ], [ 34.402841017398998, 49.050132962166458 ], [ 34.40826704278976, 49.076539618708409 ], [ 34.72649051292683, 49.145992744069872 ], [ 34.784058057833079, 49.174569810948014 ], [ 34.864776646025348, 49.173407090964474 ], [ 34.867412144354944, 49.144907538452117 ], [ 34.891131625723915, 49.148266507193512 ], [ 34.926219923610574, 49.183044744919016 ], [ 34.983942499047089, 49.163717760166492 ] ] ] } }, { "type": "Feature", "properties": { "ISO": "UA-71", "NAME_1": "Cherkasy" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 32.019213902199795, 50.251454982961093 ], [ 32.094609815990168, 50.236933905974979 ], [ 32.140705194289239, 50.182311916861465 ], [ 32.215842725661219, 50.147533678236641 ], [ 32.269689569318132, 50.156835436306324 ], [ 32.271704950023377, 50.131358953952542 ], [ 32.230570510020982, 50.118594876152599 ], [ 32.255065137745873, 50.062836005477266 ], [ 32.329427524560572, 50.051777249120732 ], [ 32.381207309769536, 49.987026678738232 ], [ 32.415675490031845, 49.96749298841064 ], [ 32.407148879217402, 49.893440659958458 ], [ 32.368649936645227, 49.887497869730851 ], [ 32.372680698055774, 49.86362335873099 ], [ 32.431023390217263, 49.805177313781996 ], [ 32.468902215165087, 49.806572577762211 ], [ 32.503525425058285, 49.78001089069005 ], [ 32.562488233944805, 49.766600856844036 ], [ 32.571479932752652, 49.705881048771346 ], [ 32.692971225741474, 49.671645413405088 ], [ 32.710799594625598, 49.619968980983685 ], [ 32.752089064258882, 49.593820705960809 ], [ 32.754414504225963, 49.547156886880828 ], [ 32.675918003213269, 49.477135322537094 ], [ 32.747593214854987, 49.406131904463791 ], [ 32.789347771582356, 49.333888251141843 ], [ 32.788727654857382, 49.261799628350161 ], [ 32.752554152252287, 49.181287747531485 ], [ 32.789502801213303, 49.146767890425792 ], [ 32.800354851994825, 49.07599701634922 ], [ 32.827174919686797, 49.027705390191556 ], [ 32.853891636389903, 49.021375027235706 ], [ 32.850791050066903, 49.005820421475221 ], [ 32.775240105746263, 48.961714586359051 ], [ 32.689405552324388, 48.987449449332587 ], [ 32.634318475217412, 48.946004950967676 ], [ 32.567759229704677, 48.957942206467635 ], [ 32.547088656915946, 48.929597682686904 ], [ 32.526934848964061, 48.927711494090147 ], [ 32.456189813309209, 49.018429470543595 ], [ 32.452934198254638, 49.056127428338129 ], [ 32.369115024638631, 49.045792141494132 ], [ 32.331907993259279, 49.089768785401077 ], [ 32.288758171652262, 49.070028387699892 ], [ 32.284572380610882, 49.047962550930947 ], [ 32.241422559903185, 49.052044990084198 ], [ 32.192536655442268, 48.999774278460166 ], [ 32.142565545363595, 48.975615545720814 ], [ 32.132953728931398, 48.961146145578141 ], [ 32.143650750082031, 48.935798855332905 ], [ 32.121326531794011, 48.910270697934322 ], [ 32.026190220302396, 48.928047389975063 ], [ 31.886715528798618, 48.85975698549646 ], [ 31.841550327385676, 48.909934801150143 ], [ 31.732306349158648, 48.942645982226281 ], [ 31.683575474328677, 48.943007717432181 ], [ 31.651122673872294, 48.917427883190214 ], [ 31.544100782824842, 48.924197495717806 ], [ 31.529476353051223, 48.904043687765977 ], [ 31.527305942715088, 48.835210680028808 ], [ 31.481210565315337, 48.803481349984281 ], [ 31.462296990813456, 48.761106676531881 ], [ 31.332899203735224, 48.723847968309087 ], [ 31.309128044623549, 48.742813218755032 ], [ 31.223655226407573, 48.753871975111565 ], [ 31.067385694793984, 48.727413641726173 ], [ 30.93814293824596, 48.756223253500309 ], [ 30.820062289942712, 48.749789536857634 ], [ 30.702291700901242, 48.765189113886493 ], [ 30.606845331047168, 48.714520371817684 ], [ 30.599403924051842, 48.659975897969275 ], [ 30.540906203158727, 48.619254869116901 ], [ 30.576097852933572, 48.604320380081447 ], [ 30.567571242119129, 48.561919868207326 ], [ 30.484423862071537, 48.55352244680347 ], [ 30.414712355191, 48.571919257367824 ], [ 30.384946729907654, 48.530526434946978 ], [ 30.247280715332749, 48.47755809223213 ], [ 30.193640578150166, 48.493474433198514 ], [ 30.108426140553945, 48.45332184512705 ], [ 30.036905959442436, 48.473785712340714 ], [ 29.957892693592896, 48.459316311298721 ], [ 30.021661411145203, 48.533627021269979 ], [ 29.963628778245493, 48.609849758259713 ], [ 29.958202751955412, 48.655325018934434 ], [ 29.859087354997428, 48.698319810910505 ], [ 29.857537062285587, 48.71769847340579 ], [ 29.884047071615043, 48.743278306748437 ], [ 29.846323276298165, 48.764827379579856 ], [ 29.772477655219575, 48.769297390562087 ], [ 29.745295851422384, 48.848543199509038 ], [ 29.703592970639079, 48.867482612432582 ], [ 29.722351514610693, 48.89696401597655 ], [ 29.716150343763331, 48.914378973710654 ], [ 29.635948521307171, 48.947038478842671 ], [ 29.672690463793856, 48.983522039810282 ], [ 29.67083011182018, 49.00140208643711 ], [ 29.593108758062726, 49.031477770082972 ], [ 29.6095418638659, 49.062121894509801 ], [ 29.670675083088611, 49.085169583209677 ], [ 29.67486087412999, 49.114625149231244 ], [ 29.707158644056165, 49.131678371759449 ], [ 29.710569288741681, 49.182760524978221 ], [ 29.674550815767532, 49.209709784778738 ], [ 29.697236769261451, 49.227279772143731 ], [ 29.745295851422384, 49.213378810983329 ], [ 29.729482863243504, 49.195214545314968 ], [ 29.761470574807163, 49.174621486892079 ], [ 29.862342970951318, 49.173846341435478 ], [ 29.920065544589193, 49.246245021690356 ], [ 30.038611280885902, 49.285105699468431 ], [ 30.111526726876946, 49.274977119098764 ], [ 30.104395379143398, 49.30128042285321 ], [ 30.15188602142274, 49.329547431368837 ], [ 30.182891880156149, 49.3214859085478 ], [ 30.169249302313403, 49.284123847537501 ], [ 30.349548373658479, 49.258053086880409 ], [ 30.382001174114862, 49.232964179053567 ], [ 30.419879999062687, 49.335955308690529 ], [ 30.437708367946811, 49.348512681814782 ], [ 30.498221470444491, 49.323863023559682 ], [ 30.550207961228409, 49.340657864568755 ], [ 30.592737665211075, 49.329935004097138 ], [ 30.619402704171421, 49.35853791029632 ], [ 30.684566684804622, 49.353835354418038 ], [ 30.725391066444558, 49.320788276557664 ], [ 30.868173048947028, 49.36063080536735 ], [ 30.908015577756714, 49.346135565903637 ], [ 30.950390252108434, 49.414296780072334 ], [ 31.155855747444775, 49.556381131483988 ], [ 31.156785923431585, 49.585190742358861 ], [ 31.129759149265283, 49.595784409822613 ], [ 31.128208855654123, 49.614439601906099 ], [ 31.174614292315709, 49.671748766192593 ], [ 31.169808383649979, 49.695519925304268 ], [ 31.196938510603786, 49.753449205416473 ], [ 31.182055698411716, 49.773809718943369 ], [ 31.198850539420846, 49.804350491481955 ], [ 31.178645053726257, 49.818251450843775 ], [ 31.21202802927013, 49.853959866354728 ], [ 31.335999790058224, 49.896747951856469 ], [ 31.430205925562973, 49.906101385870215 ], [ 31.496920199807334, 49.840033066772605 ], [ 31.5193994477263, 49.866465563534916 ], [ 31.570920851416133, 49.862693182744181 ], [ 31.596552361602164, 49.897626450999894 ], [ 31.613140497036284, 49.858145657396165 ], [ 31.716648389711395, 49.848533840963967 ], [ 31.806772087861532, 49.96020661104626 ], [ 31.810027703815479, 50.00563019487754 ], [ 31.871987746237494, 50.023458563761665 ], [ 31.849611851106033, 50.094978745772494 ], [ 31.91431074374583, 50.147275295818247 ], [ 31.954359979029789, 50.147533678236641 ], [ 31.987536248998651, 50.169289455743751 ], [ 31.995339390300614, 50.186497707902845 ], [ 31.975133904605968, 50.224066474488154 ], [ 32.019213902199795, 50.251454982961093 ] ] ] } }, { "type": "Feature", "properties": { "ISO": "UA-35", "NAME_1": "Kirovohrad" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 32.788727654857382, 49.261799628350161 ], [ 33.038221470044789, 49.18195954020058 ], [ 33.149325799346116, 49.125632228744337 ], [ 33.204102818090576, 49.070906886843318 ], [ 33.297068719245999, 49.064860744727582 ], [ 33.34161380393391, 49.044035143207282 ], [ 33.345954623706916, 49.032666328488233 ], [ 33.31923790880245, 49.025586655799486 ], [ 33.29939415921308, 48.995459296209503 ], [ 33.378820835313263, 48.939958807952621 ], [ 33.425071242343961, 48.950914212420969 ], [ 33.548422886407081, 48.901873277429786 ], [ 33.596998731606163, 48.922388821486891 ], [ 33.651620720719677, 48.899289455943631 ], [ 33.668880649722212, 48.869058743566143 ], [ 33.694615512695748, 48.905800686052771 ], [ 33.778486362255819, 48.92254385021846 ], [ 33.807425165239181, 48.903449409462667 ], [ 33.892019484311732, 48.891744697060119 ], [ 33.919459669628054, 48.865803128511573 ], [ 33.82571862211671, 48.759453030133216 ], [ 33.810990837756947, 48.761985174775987 ], [ 33.801844110217473, 48.796143297575099 ], [ 33.774817336051171, 48.77898672136007 ], [ 33.773732131332793, 48.801414293334972 ], [ 33.754043409575672, 48.807563788238213 ], [ 33.70887820816273, 48.782500718833091 ], [ 33.670844353584016, 48.807408759506643 ], [ 33.619374626737624, 48.784231878698222 ], [ 33.584441359381231, 48.794489651176434 ], [ 33.557569613946498, 48.771984564835748 ], [ 33.598549025217324, 48.721832586704465 ], [ 33.756523879173642, 48.697699693286211 ], [ 33.769701369022926, 48.680543117970501 ], [ 33.821067743081869, 48.668399155996212 ], [ 33.580410597071364, 48.559413561086956 ], [ 33.4454317558708, 48.525307115131227 ], [ 33.443261346433985, 48.504274807136596 ], [ 33.461554803311515, 48.497763577027456 ], [ 33.484499139223885, 48.443916734269862 ], [ 33.471476678106171, 48.422961941540336 ], [ 33.488529900634376, 48.403945014251008 ], [ 33.500002069040249, 48.311211656192938 ], [ 33.525943637588796, 48.297414048719361 ], [ 33.513386265363863, 48.238502915776905 ], [ 33.470546502119362, 48.227986761779619 ], [ 33.40290205278751, 48.175250963061444 ], [ 33.348125034043051, 48.185947984212078 ], [ 33.268801710730372, 48.102723089798758 ], [ 33.241981642139081, 48.173907375924614 ], [ 33.2222929221806, 48.090449938413883 ], [ 33.111291944767402, 48.064198309704182 ], [ 33.057496778853249, 48.025802720818888 ], [ 33.035275913352677, 47.966529853569853 ], [ 32.975692986841864, 48.044509588846438 ], [ 32.888204787021266, 48.032158922196459 ], [ 32.882778761630505, 47.993970037986855 ], [ 32.844434848689275, 47.986218574427653 ], [ 32.829345330922251, 47.966684882301479 ], [ 32.777565544814024, 47.989319159851334 ], [ 32.696071812064474, 47.970302232561949 ], [ 32.681964146228324, 47.943223782451582 ], [ 32.714727004147903, 47.929736233340464 ], [ 32.715760532022898, 47.91521515635435 ], [ 32.662740513363985, 47.900254827997912 ], [ 32.68227420369152, 47.845865382881072 ], [ 32.651991815369968, 47.83733877206663 ], [ 32.644550409273961, 47.800648505524066 ], [ 32.467041864090731, 47.781088975875491 ], [ 32.342915072772371, 47.796178494541891 ], [ 32.287517938202257, 47.822455959874617 ], [ 32.234446241800583, 47.820440579169372 ], [ 32.169127232435812, 47.757705390391436 ], [ 32.138689812684675, 47.747525133178328 ], [ 32.021849399630071, 47.809846909906923 ], [ 31.862324252961912, 47.795842596858336 ], [ 31.839069858687026, 47.845968736567897 ], [ 31.863409457680291, 47.89438955303541 ], [ 31.821241488903581, 47.945187486313444 ], [ 31.768169793401228, 47.952577216465329 ], [ 31.708070102952888, 48.019756577803776 ], [ 31.769254999018983, 48.047041734388529 ], [ 31.758041213031561, 48.09541087491192 ], [ 31.69861331615158, 48.093757229412574 ], [ 31.61655114082248, 48.118742784451911 ], [ 31.591281365842349, 48.109570216692134 ], [ 31.584873487621337, 48.086212470529063 ], [ 31.533662144092659, 48.086780911309972 ], [ 31.511803012898724, 48.055051581265445 ], [ 31.499710727767877, 48.063888251341723 ], [ 31.503741489178367, 48.118096829305216 ], [ 31.414496291070975, 48.107709866517098 ], [ 31.384265577794167, 48.124943956198592 ], [ 31.343906284147693, 48.111792303871709 ], [ 31.250320265367918, 48.120939033209766 ], [ 31.231096633402899, 48.131636054360399 ], [ 31.228151075811468, 48.158895372523375 ], [ 31.189807162870238, 48.171478583170028 ], [ 31.185466343097232, 48.205068264288968 ], [ 31.168568150199974, 48.211682848084934 ], [ 31.041805860552017, 48.21912425418094 ], [ 30.906155225783039, 48.157112534915541 ], [ 30.857114291691175, 48.186258043473913 ], [ 30.816961703619711, 48.159076240576042 ], [ 30.789004754365919, 48.177783107704215 ], [ 30.704772169599948, 48.181581326017351 ], [ 30.619867791265563, 48.145046088206357 ], [ 30.501787143861577, 48.163572089080617 ], [ 30.347532992953234, 48.163572089080617 ], [ 30.322314893917223, 48.134607449474174 ], [ 30.083466423936272, 48.143728339491247 ], [ 30.04780968616808, 48.155200506997801 ], [ 29.996753371370971, 48.220984605255296 ], [ 29.959442987204056, 48.209589952114584 ], [ 29.924096306899003, 48.221553046036206 ], [ 29.882186721440007, 48.18000519488379 ], [ 29.825859409084444, 48.210106716052053 ], [ 29.774803094287336, 48.203104560427107 ], [ 29.786430291424779, 48.250801907382083 ], [ 29.738939650044813, 48.267079983554368 ], [ 29.775423211012367, 48.316896064002151 ], [ 29.779298943691288, 48.35309540502891 ], [ 29.883426954890012, 48.432599596394255 ], [ 29.916964959165512, 48.428310452565313 ], [ 29.940064324708771, 48.375600491369539 ], [ 29.998096957608482, 48.42205760397519 ], [ 30.038766309617472, 48.427690334940962 ], [ 30.076128370627771, 48.455078844313164 ], [ 30.120828484946628, 48.45639659302833 ], [ 30.184752232129767, 48.492492581267584 ], [ 30.247280715332749, 48.47755809223213 ], [ 30.384946729907654, 48.530526434946978 ], [ 30.408821241806834, 48.570213935025038 ], [ 30.456466912817746, 48.570782375805948 ], [ 30.484423862071537, 48.55352244680347 ], [ 30.57408247222827, 48.565149644840233 ], [ 30.576097852933572, 48.604320380081447 ], [ 30.540906203158727, 48.619254869116901 ], [ 30.599403924051842, 48.659975897969275 ], [ 30.606845331047168, 48.714520371817684 ], [ 30.702291700901242, 48.765189113886493 ], [ 30.820062289942712, 48.749789536857634 ], [ 30.93814293824596, 48.756223253500309 ], [ 31.067385694793984, 48.727413641726173 ], [ 31.223655226407573, 48.753871975111565 ], [ 31.309128044623549, 48.742813218755032 ], [ 31.332899203735224, 48.723847968309087 ], [ 31.462296990813456, 48.761106676531881 ], [ 31.481210565315337, 48.803481349984281 ], [ 31.527305942715088, 48.835210680028808 ], [ 31.529476353051223, 48.904043687765977 ], [ 31.544100782824842, 48.924197495717806 ], [ 31.651122673872294, 48.917427883190214 ], [ 31.683575474328677, 48.943007717432181 ], [ 31.732306349158648, 48.942645982226281 ], [ 31.841550327385676, 48.909934801150143 ], [ 31.886715528798618, 48.85975698549646 ], [ 32.026190220302396, 48.928047389975063 ], [ 32.129233025883423, 48.912518621736979 ], [ 32.142565545363595, 48.975615545720814 ], [ 32.192536655442268, 48.999774278460166 ], [ 32.241422559903185, 49.052044990084198 ], [ 32.284572380610882, 49.047962550930947 ], [ 32.288758171652262, 49.070028387699892 ], [ 32.331907993259279, 49.089768785401077 ], [ 32.369115024638631, 49.045792141494132 ], [ 32.452934198254638, 49.056127428338129 ], [ 32.454484490966479, 49.02160757123238 ], [ 32.503525425058285, 48.9730834010781 ], [ 32.51980350123057, 48.929106757171098 ], [ 32.547088656915946, 48.929597682686904 ], [ 32.567759229704677, 48.957942206467635 ], [ 32.634318475217412, 48.946004950967676 ], [ 32.689405552324388, 48.987449449332587 ], [ 32.782371454379074, 48.963729967064296 ], [ 32.855441929101744, 49.018403632121874 ], [ 32.82469445188741, 49.030392564465274 ], [ 32.800354851994825, 49.07599701634922 ], [ 32.789502801213303, 49.146767890425792 ], [ 32.752554152252287, 49.181287747531485 ], [ 32.788727654857382, 49.261799628350161 ] ] ] } }, -{ "type": "Feature", "properties": { "ISO": "UA-30", "NAME_1": "Kiev City" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 30.738579569391618, 50.668153050722708 ], [ 30.834853259770114, 50.63879710530216 ], [ 30.842339119354847, 50.583978551369 ], [ 30.77560546292176, 50.544198930136474 ], [ 30.750571941641795, 50.506299492727408 ], [ 30.768079276837113, 50.488703509559286 ], [ 30.760252259434196, 50.43988011157785 ], [ 30.817012763528169, 50.367226506797806 ], [ 30.846045721033192, 50.353492985013247 ], [ 30.830268158049023, 50.332724846574365 ], [ 30.790160822064252, 50.332587875330148 ], [ 30.791965799182265, 50.292555746950143 ], [ 30.753676885180084, 50.2891579715631 ], [ 30.724343095457471, 50.309563514144827 ], [ 30.718108877222903, 50.262483327843256 ], [ 30.605901274923895, 50.267452462561096 ], [ 30.654383518684426, 50.155983494142902 ], [ 30.639759213017271, 50.12409198125988 ], [ 30.620784347019992, 50.122712035432073 ], [ 30.648197621922691, 50.03369265169448 ], [ 30.585565493652609, 50.014154717466226 ], [ 30.568478920665655, 50.091072387580596 ], [ 30.541697786423697, 50.097418012031596 ], [ 30.536180761333071, 50.145639750973714 ], [ 30.511085473715639, 50.146179644574886 ], [ 30.520806118907217, 50.190074235237716 ], [ 30.476911529143763, 50.199794880429351 ], [ 30.445622347093604, 50.263568562239357 ], [ 30.41059423723425, 50.261989207036265 ], [ 30.413718066535523, 50.303914962305612 ], [ 30.332238312847835, 50.368768432217337 ], [ 30.318757298210699, 50.40827410826347 ], [ 30.238241797620844, 50.414672186572147 ], [ 30.223303712196525, 50.382020633245588 ], [ 30.19660342970451, 50.374352670839073 ], [ 30.185993737221168, 50.387245468603453 ], [ 30.20028059567295, 50.478145037963088 ], [ 30.217438779875749, 50.542431728141707 ], [ 30.258627859088392, 50.565877710388008 ], [ 30.249909840166993, 50.600723265965939 ], [ 30.267873699111931, 50.608004352618764 ], [ 30.285561019225611, 50.588952523540115 ], [ 30.291309143102467, 50.609275474183733 ], [ 30.270164967539188, 50.63339306383881 ], [ 30.333473963352731, 50.637918952397683 ], [ 30.334013856054582, 50.663014239115796 ], [ 30.432651793045807, 50.66244745938269 ], [ 30.437266331376065, 50.634241784630603 ], [ 30.462512034202973, 50.630365880181216 ], [ 30.475767426025868, 50.595864218159136 ], [ 30.548079872987728, 50.548982771237888 ], [ 30.538686942548338, 50.571958808252361 ], [ 30.551813308637236, 50.580783641451887 ], [ 30.647182495975414, 50.575179282297995 ], [ 30.664327235313635, 50.602694650937337 ], [ 30.718544131106739, 50.62352455391499 ], [ 30.71559759865022, 50.651805437171163 ], [ 30.738579569391618, 50.668153050722708 ] ] ] } } +{ "type": "Feature", "properties": { "ISO": "UA-30", "NAME_1": "Kyiv City" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 30.738579569391618, 50.668153050722708 ], [ 30.834853259770114, 50.63879710530216 ], [ 30.842339119354847, 50.583978551369 ], [ 30.77560546292176, 50.544198930136474 ], [ 30.750571941641795, 50.506299492727408 ], [ 30.768079276837113, 50.488703509559286 ], [ 30.760252259434196, 50.43988011157785 ], [ 30.817012763528169, 50.367226506797806 ], [ 30.846045721033192, 50.353492985013247 ], [ 30.830268158049023, 50.332724846574365 ], [ 30.790160822064252, 50.332587875330148 ], [ 30.791965799182265, 50.292555746950143 ], [ 30.753676885180084, 50.2891579715631 ], [ 30.724343095457471, 50.309563514144827 ], [ 30.718108877222903, 50.262483327843256 ], [ 30.605901274923895, 50.267452462561096 ], [ 30.654383518684426, 50.155983494142902 ], [ 30.639759213017271, 50.12409198125988 ], [ 30.620784347019992, 50.122712035432073 ], [ 30.648197621922691, 50.03369265169448 ], [ 30.585565493652609, 50.014154717466226 ], [ 30.568478920665655, 50.091072387580596 ], [ 30.541697786423697, 50.097418012031596 ], [ 30.536180761333071, 50.145639750973714 ], [ 30.511085473715639, 50.146179644574886 ], [ 30.520806118907217, 50.190074235237716 ], [ 30.476911529143763, 50.199794880429351 ], [ 30.445622347093604, 50.263568562239357 ], [ 30.41059423723425, 50.261989207036265 ], [ 30.413718066535523, 50.303914962305612 ], [ 30.332238312847835, 50.368768432217337 ], [ 30.318757298210699, 50.40827410826347 ], [ 30.238241797620844, 50.414672186572147 ], [ 30.223303712196525, 50.382020633245588 ], [ 30.19660342970451, 50.374352670839073 ], [ 30.185993737221168, 50.387245468603453 ], [ 30.20028059567295, 50.478145037963088 ], [ 30.217438779875749, 50.542431728141707 ], [ 30.258627859088392, 50.565877710388008 ], [ 30.249909840166993, 50.600723265965939 ], [ 30.267873699111931, 50.608004352618764 ], [ 30.285561019225611, 50.588952523540115 ], [ 30.291309143102467, 50.609275474183733 ], [ 30.270164967539188, 50.63339306383881 ], [ 30.333473963352731, 50.637918952397683 ], [ 30.334013856054582, 50.663014239115796 ], [ 30.432651793045807, 50.66244745938269 ], [ 30.437266331376065, 50.634241784630603 ], [ 30.462512034202973, 50.630365880181216 ], [ 30.475767426025868, 50.595864218159136 ], [ 30.548079872987728, 50.548982771237888 ], [ 30.538686942548338, 50.571958808252361 ], [ 30.551813308637236, 50.580783641451887 ], [ 30.647182495975414, 50.575179282297995 ], [ 30.664327235313635, 50.602694650937337 ], [ 30.718544131106739, 50.62352455391499 ], [ 30.71559759865022, 50.651805437171163 ], [ 30.738579569391618, 50.668153050722708 ] ] ] } }, +{ "type": "Feature", "properties": { "ISO": "UA-43", "NAME_1": "Crimea" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 33.745771311300061, 44.402323389538829 ], [ 33.852068870481958, 44.431631863719531 ], [ 33.805710906333218, 44.527306810660775 ], [ 33.712994978035795, 44.581555492092264 ], [ 33.709049619844052, 44.666380702207618 ], [ 33.611401992907645, 44.720629383639107 ], [ 33.67452773116986, 44.791645839184127 ], [ 33.588428605039795, 44.842004786558498 ], [ 33.612207031250023, 44.9078125 ], [ 33.601171875, 44.981494140624996 ], [ 33.55517578125, 45.09765625 ], [ 33.392480468750023, 45.187841796874999 ], [ 33.261523437500017, 45.170751953124999 ], [ 33.186914062500023, 45.194775390624997 ], [ 32.918652343750011, 45.34814453125 ], [ 32.772656250000011, 45.358984375 ], [ 32.611328125, 45.328076171874997 ], [ 32.551855468750006, 45.350390625 ], [ 32.508007812500011, 45.40380859375 ], [ 32.828027343750023, 45.593017578125 ], [ 33.142285156250011, 45.74921875 ], [ 33.280078125000017, 45.765234375 ], [ 33.466210937500023, 45.837939453124996 ], [ 33.664843750000017, 45.947070312499996 ], [ 33.63671875, 46.032861328124994 ], [ 33.594140625000023, 46.096240234374996 ], [ 33.654322809145384, 46.146221891615326 ], [ 33.659965189140848, 46.21957283155632 ], [ 33.806667069022836, 46.208288071565399 ], [ 34.026719888845811, 46.106725231647104 ], [ 34.128282728764113, 46.089798091660718 ], [ 34.22420318868695, 46.10108285165164 ], [ 34.353977928582552, 46.061586191683411 ], [ 34.449898388505389, 45.965665731760573 ], [ 34.523249328446383, 45.976950491751495 ], [ 34.68687834831475, 45.976950491751495 ], [ 34.794083568228508, 45.89231479181958 ], [ 34.799725948223973, 45.790751951901285 ], [ 34.946427828105953, 45.728685771951213 ], [ 35.001674239407095, 45.733383205653169 ], [ 35.022851562500023, 45.700976562499996 ], [ 35.260156250000023, 45.446923828124994 ], [ 35.373925781250023, 45.353613281249999 ], [ 35.45751953125, 45.316308593749994 ], [ 35.558007812500023, 45.310888671874999 ], [ 35.7509765625, 45.389355468749997 ], [ 35.83349609375, 45.401611328125 ], [ 36.012890625000011, 45.371679687499999 ], [ 36.0771484375, 45.424121093749996 ], [ 36.170507812500006, 45.453076171874997 ], [ 36.290332031250017, 45.456738281249997 ], [ 36.427050781250017, 45.433251953124994 ], [ 36.575, 45.3935546875 ], [ 36.514257812500006, 45.303759765624996 ], [ 36.45078125, 45.232324218749994 ], [ 36.428417968750011, 45.153271484374997 ], [ 36.393359375000017, 45.065380859374997 ], [ 36.229882812500023, 45.025976562499999 ], [ 36.054785156250006, 45.030810546874996 ], [ 35.8701171875, 45.005322265624997 ], [ 35.803613281250023, 45.039599609374996 ], [ 35.759472656250011, 45.070849609374996 ], [ 35.677539062500017, 45.102001953124997 ], [ 35.569531250000011, 45.119335937499997 ], [ 35.472558593750023, 45.098486328124999 ], [ 35.357812500000023, 44.978417968749994 ], [ 35.15478515625, 44.896337890624999 ], [ 35.087695312500017, 44.802636718749994 ], [ 34.887792968750006, 44.823583984374999 ], [ 34.716894531250006, 44.80712890625 ], [ 34.469921875000011, 44.7216796875 ], [ 34.28173828125, 44.538427734374999 ], [ 34.074414062500011, 44.423828125 ], [ 33.909960937500017, 44.387597656249994 ], [ 33.755664062500017, 44.39892578125 ], [ 33.745771311300061, 44.402323389538829 ] ] ] } } ] } diff --git a/superset-frontend/plugins/legacy-plugin-chart-force-directed/CHANGELOG.md b/superset-frontend/plugins/legacy-plugin-chart-force-directed/CHANGELOG.md deleted file mode 100644 index 7a9c9279bade8..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-force-directed/CHANGELOG.md +++ /dev/null @@ -1,27 +0,0 @@ - - -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [0.18.0](https://github.com/apache-superset/superset-ui/compare/v0.17.87...v0.18.0) (2021-08-30) - -**Note:** Version bump only for package @superset-ui/legacy-plugin-chart-force-directed diff --git a/superset-frontend/plugins/legacy-plugin-chart-force-directed/README.md b/superset-frontend/plugins/legacy-plugin-chart-force-directed/README.md deleted file mode 100644 index 4917d6205e111..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-force-directed/README.md +++ /dev/null @@ -1,52 +0,0 @@ - - -## @superset-ui/legacy-plugin-chart-force-directed - -[![Version](https://img.shields.io/npm/v/@superset-ui/legacy-plugin-chart-force-directed.svg?style=flat-square)](https://www.npmjs.com/package/@superset-ui/legacy-plugin-chart-force-directed) -[![David (path)](https://img.shields.io/david/apache-superset/superset-ui-plugins.svg?path=packages%2Fsuperset-ui-legacy-plugin-chart-force-directed&style=flat-square)](https://david-dm.org/apache-superset/superset-ui-plugins?path=packages/superset-ui-legacy-plugin-chart-force-directed) - -This plugin provides Force-directed Graph for Superset. - -### Usage - -Configure `key`, which can be any `string`, and register the plugin. This `key` will be used to -lookup this chart throughout the app. - -```js -import ChordChartPlugin from '@superset-ui/legacy-plugin-chart-force-directed'; - -new ChordChartPlugin().configure({ key: 'force-directed' }).register(); -``` - -Then use it via `SuperChart`. See -[storybook](https://apache-superset.github.io/superset-ui-plugins/?selectedKind=plugin-chart-force-directed) -for more details. - -```js - -``` diff --git a/superset-frontend/plugins/legacy-plugin-chart-force-directed/package.json b/superset-frontend/plugins/legacy-plugin-chart-force-directed/package.json deleted file mode 100644 index da16cde635c2d..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-force-directed/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@superset-ui/legacy-plugin-chart-force-directed", - "version": "0.18.25", - "description": "Superset Legacy Chart - Force-directed Graph", - "sideEffects": [ - "*.css" - ], - "main": "lib/index.js", - "module": "esm/index.js", - "files": [ - "esm", - "lib" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/apache-superset/superset-ui.git" - }, - "keywords": [ - "superset" - ], - "author": "Superset", - "license": "Apache-2.0", - "bugs": { - "url": "https://github.com/apache-superset/superset-ui/issues" - }, - "homepage": "https://github.com/apache-superset/superset-ui#readme", - "publishConfig": { - "access": "public" - }, - "dependencies": { - "d3": "^3.5.17", - "prop-types": "^15.7.2" - }, - "peerDependencies": { - "@superset-ui/chart-controls": "*", - "@superset-ui/core": "*", - "react": "^16.13.1" - } -} diff --git a/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/ForceDirected.js b/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/ForceDirected.js deleted file mode 100644 index ac5847ecbced6..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/ForceDirected.js +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -/* eslint-disable react/sort-prop-types, func-names, no-param-reassign */ -import d3 from 'd3'; -import PropTypes from 'prop-types'; - -const propTypes = { - data: PropTypes.arrayOf( - PropTypes.shape({ - source: PropTypes.string, - target: PropTypes.string, - value: PropTypes.number, - }), - ), - width: PropTypes.number, - height: PropTypes.number, - linkLength: PropTypes.number, - charge: PropTypes.number, -}; - -/* Modified from http://bl.ocks.org/d3noob/5141278 */ -function ForceDirected(element, props) { - const { data, width, height, linkLength = 200, charge = -500 } = props; - const div = d3.select(element); - div.classed('superset-legacy-chart-force-directed', true); - - const links = data; - const nodes = {}; - // Compute the distinct nodes from the links. - links.forEach(link => { - link.source = - nodes[link.source] || - (nodes[link.source] = { - name: link.source, - }); - link.target = - nodes[link.target] || - (nodes[link.target] = { - name: link.target, - }); - link.value = Number(link.value); - - const targetName = link.target.name; - const sourceName = link.source.name; - - if (nodes[targetName].total === undefined) { - nodes[targetName].total = link.value; - } - if (nodes[sourceName].total === undefined) { - nodes[sourceName].total = 0; - } - if (nodes[targetName].max === undefined) { - nodes[targetName].max = 0; - } - if (link.value > nodes[targetName].max) { - nodes[targetName].max = link.value; - } - if (nodes[targetName].min === undefined) { - nodes[targetName].min = 0; - } - if (link.value > nodes[targetName].min) { - nodes[targetName].min = link.value; - } - - nodes[targetName].total += link.value; - }); - - /* eslint-disable no-use-before-define */ - // add the curvy lines - function tick() { - path.attr('d', d => { - const dx = d.target.x - d.source.x; - const dy = d.target.y - d.source.y; - const dr = Math.sqrt(dx * dx + dy * dy); - - return `M${d.source.x},${d.source.y}A${dr},${dr} 0 0,1 ${d.target.x},${d.target.y}`; - }); - - node.attr('transform', d => `translate(${d.x},${d.y})`); - } - /* eslint-enable no-use-before-define */ - - const force = d3.layout - .force() - .nodes(d3.values(nodes)) - .links(links) - .size([width, height]) - .linkDistance(linkLength) - .charge(charge) - .on('tick', tick) - .start(); - - div.selectAll('*').remove(); - const svg = div.append('svg').attr('width', width).attr('height', height); - - // build the arrow. - svg - .append('svg:defs') - .selectAll('marker') - .data(['end']) // Different link/path types can be defined here - .enter() - .append('svg:marker') // This section adds in the arrows - .attr('id', String) - .attr('viewBox', '0 -5 10 10') - .attr('refX', 15) - .attr('refY', -1.5) - .attr('markerWidth', 6) - .attr('markerHeight', 6) - .attr('orient', 'auto') - .append('svg:path') - .attr('d', 'M0,-5L10,0L0,5'); - - const edgeScale = d3.scale.linear().range([0.1, 0.5]); - // add the links and the arrows - const path = svg - .append('svg:g') - .selectAll('path') - .data(force.links()) - .enter() - .append('svg:path') - .attr('class', 'link') - .style('opacity', d => edgeScale(d.value / d.target.max)) - .attr('marker-end', 'url(#end)'); - - // define the nodes - const node = svg - .selectAll('.node') - .data(force.nodes()) - .enter() - .append('g') - .attr('class', 'node') - .on('mouseenter', function () { - d3.select(this).select('circle').transition().style('stroke-width', 5); - - d3.select(this).select('text').transition().style('font-size', 25); - }) - .on('mouseleave', function () { - d3.select(this).select('circle').transition().style('stroke-width', 1.5); - d3.select(this).select('text').transition().style('font-size', 12); - }) - .call(force.drag); - - // add the nodes - const ext = d3.extent(d3.values(nodes), d => Math.sqrt(d.total)); - const circleScale = d3.scale.linear().domain(ext).range([3, 30]); - - node.append('circle').attr('r', d => circleScale(Math.sqrt(d.total))); - - // add the text - node - .append('text') - .attr('x', 6) - .attr('dy', '.35em') - .text(d => d.name); -} - -ForceDirected.displayName = 'ForceDirected'; -ForceDirected.propTypes = propTypes; - -export default ForceDirected; diff --git a/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/controlPanel.ts b/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/controlPanel.ts deleted file mode 100644 index 575507ba465c0..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/controlPanel.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { t } from '@superset-ui/core'; -import { formatSelectOptions, sections } from '@superset-ui/chart-controls'; - -export default { - controlPanelSections: [ - sections.legacyRegularTime, - { - label: t('Query'), - expanded: true, - controlSetRows: [ - ['groupby'], - ['metric'], - ['adhoc_filters'], - ['row_limit'], - [ - { - name: 'sort_by_metric', - config: { - type: 'CheckboxControl', - label: t('Sort by metric'), - description: t( - 'Whether to sort results by the selected metric in descending order.', - ), - }, - }, - ], - ], - }, - { - label: t('Chart Options'), - expanded: true, - controlSetRows: [ - [ - { - name: 'link_length', - config: { - type: 'SelectControl', - renderTrigger: true, - freeForm: true, - label: t('Link Length'), - default: '200', - choices: formatSelectOptions([ - '10', - '25', - '50', - '75', - '100', - '150', - '200', - '250', - ]), - description: t('Link length in the force layout'), - }, - }, - ], - [ - { - name: 'charge', - config: { - type: 'SelectControl', - renderTrigger: true, - freeForm: true, - label: t('Charge'), - default: '-500', - choices: formatSelectOptions([ - '-50', - '-75', - '-100', - '-150', - '-200', - '-250', - '-500', - '-1000', - '-2500', - '-5000', - ]), - description: t('Charge in the force layout'), - }, - }, - ], - ], - }, - ], - controlOverrides: { - groupby: { - label: t('Source / Target'), - description: t('Choose a source and a target'), - }, - }, -}; diff --git a/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/images/thumbnail.png b/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/images/thumbnail.png deleted file mode 100644 index e7fad14aa59ea..0000000000000 Binary files a/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/images/thumbnail.png and /dev/null differ diff --git a/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/images/thumbnailLarge.png b/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/images/thumbnailLarge.png deleted file mode 100644 index d3d30319fdf27..0000000000000 Binary files a/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/images/thumbnailLarge.png and /dev/null differ diff --git a/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/transformProps.js b/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/transformProps.js deleted file mode 100644 index d463407bd12af..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/transformProps.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -export default function transformProps(chartProps) { - const { width, height, formData, queriesData } = chartProps; - const { charge, linkLength } = formData; - - return { - charge, - data: queriesData[0].data, - height, - linkLength, - width, - }; -} diff --git a/superset-frontend/plugins/legacy-plugin-chart-force-directed/tsconfig.json b/superset-frontend/plugins/legacy-plugin-chart-force-directed/tsconfig.json deleted file mode 100644 index b6bfaa2d98446..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-force-directed/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "declarationDir": "lib", - "outDir": "lib", - "rootDir": "src" - }, - "exclude": [ - "lib", - "test" - ], - "extends": "../../tsconfig.json", - "include": [ - "src/**/*", - "types/**/*", - "../../types/**/*" - ], - "references": [ - { - "path": "../../packages/superset-ui-chart-controls" - }, - { - "path": "../../packages/superset-ui-core" - } - ] -} diff --git a/superset-frontend/plugins/legacy-plugin-chart-horizon/package.json b/superset-frontend/plugins/legacy-plugin-chart-horizon/package.json index d4d2c75606ae0..e0c2344761a51 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-horizon/package.json +++ b/superset-frontend/plugins/legacy-plugin-chart-horizon/package.json @@ -2,31 +2,25 @@ "name": "@superset-ui/legacy-plugin-chart-horizon", "version": "0.18.25", "description": "Superset Legacy Chart - Horizon", - "sideEffects": [ - "*.css" - ], - "main": "lib/index.js", - "module": "esm/index.js", - "files": [ - "esm", - "lib" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/apache-superset/superset-ui.git" - }, "keywords": [ "superset" ], - "author": "Superset", - "license": "Apache-2.0", + "homepage": "https://github.com/apache-superset/superset-ui#readme", "bugs": { "url": "https://github.com/apache-superset/superset-ui/issues" }, - "homepage": "https://github.com/apache-superset/superset-ui#readme", - "publishConfig": { - "access": "public" + "repository": { + "type": "git", + "url": "git+https://github.com/apache-superset/superset-ui.git" }, + "license": "Apache-2.0", + "author": "Superset", + "main": "lib/index.js", + "module": "esm/index.js", + "files": [ + "esm", + "lib" + ], "dependencies": { "d3-array": "^2.0.3", "d3-scale": "^3.0.1", @@ -36,5 +30,8 @@ "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", "react": "^15 || ^16" + }, + "publishConfig": { + "access": "public" } } diff --git a/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.css b/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.css deleted file mode 100644 index bbdf6d6889b41..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.css +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -.superset-legacy-chart-horizon { - overflow: auto; - position: relative; -} - -.superset-legacy-chart-horizon .horizon-row { - border-bottom: solid 1px #ddd; - border-top: 0px; - padding: 0px; - margin: 0px; -} - -.superset-legacy-chart-horizon .horizon-row span.title { - position: absolute; - color: #333; - font-size: 0.8em; - margin: 0; - text-shadow: 1px 1px rgba(255, 255, 255, 0.75); -} diff --git a/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.jsx b/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.jsx index b71227b6cd105..e4a32c8de3569 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.jsx +++ b/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.jsx @@ -20,9 +20,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { extent as d3Extent } from 'd3-array'; -import { ensureIsArray } from '@superset-ui/core'; +import { ensureIsArray, styled } from '@superset-ui/core'; import HorizonRow, { DEFAULT_COLORS } from './HorizonRow'; -import './HorizonChart.css'; const propTypes = { className: PropTypes.string, @@ -58,6 +57,29 @@ const defaultProps = { offsetX: 0, }; +const StyledDiv = styled.div` + ${({ theme }) => ` + .superset-legacy-chart-horizon { + overflow: auto; + position: relative; + } + + .superset-legacy-chart-horizon .horizon-row { + border-bottom: solid 1px ${theme.colors.grayscale.light2}; + border-top: 0; + padding: 0; + margin: 0; + } + + .superset-legacy-chart-horizon .horizon-row span.title { + position: absolute; + color: ${theme.colors.grayscale.dark1}; + font-size: ${theme.typography.sizes.s}px; + margin: 0; + } + `} +`; + class HorizonChart extends React.PureComponent { render() { const { @@ -83,26 +105,28 @@ class HorizonChart extends React.PureComponent { } return ( -
- {data.map(row => ( - - ))} -
+ +
+ {data.map(row => ( + + ))} +
+
); } } diff --git a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/package.json b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/package.json index 5d5a085d91ffd..de3791bbe3a47 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/package.json +++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/package.json @@ -2,31 +2,25 @@ "name": "@superset-ui/legacy-plugin-chart-paired-t-test", "version": "0.18.25", "description": "Superset Legacy Chart - Paired T Test", - "sideEffects": [ - "*.css" - ], - "main": "lib/index.js", - "module": "esm/index.js", - "files": [ - "esm", - "lib" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/apache-superset/superset-ui.git" - }, "keywords": [ "superset" ], - "author": "Superset", - "license": "Apache-2.0", + "homepage": "https://github.com/apache-superset/superset-ui#readme", "bugs": { "url": "https://github.com/apache-superset/superset-ui/issues" }, - "homepage": "https://github.com/apache-superset/superset-ui#readme", - "publishConfig": { - "access": "public" + "repository": { + "type": "git", + "url": "git+https://github.com/apache-superset/superset-ui.git" }, + "license": "Apache-2.0", + "author": "Superset", + "main": "lib/index.js", + "module": "esm/index.js", + "files": [ + "esm", + "lib" + ], "dependencies": { "distributions": "^1.0.0", "prop-types": "^15.6.2", @@ -36,5 +30,8 @@ "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", "react": "^15 || ^16" + }, + "publishConfig": { + "access": "public" } } diff --git a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.css b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.css deleted file mode 100644 index ee62864b25941..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.css +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -.superset-legacy-chart-paired_ttest .scrollbar-container { - overflow: auto; -} - -.paired-ttest-table .scrollbar-content { - padding-left: 5px; - padding-right: 5px; - margin-bottom: 0; -} - -.paired-ttest-table table { - margin-bottom: 0; -} - -.paired-ttest-table h1 { - margin-left: 5px; -} - -.reactable-data tr, -.reactable-header-sortable { - -webkit-transition: ease-in-out 0.1s; - transition: ease-in-out 0.1s; -} - -.reactable-data tr:hover { - background-color: #e0e0e0; -} - -.reactable-data tr .false { - color: #f44336; -} - -.reactable-data tr .true { - color: #4caf50; -} - -.reactable-data tr .control { - color: #2196f3; -} - -.reactable-data tr .invalid { - color: #ff9800; -} - -.reactable-data .control td { - background-color: #eeeeee; -} - -.reactable-header-sortable:hover, -.reactable-header-sortable:focus, -.reactable-header-sort-asc, -.reactable-header-sort-desc { - background-color: #e0e0e0; - position: relative; -} - -.reactable-header-sort-asc:after { - content: '\25bc'; - position: absolute; - right: 10px; -} - -.reactable-header-sort-desc:after { - content: '\25b2'; - position: absolute; - right: 10px; -} diff --git a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.jsx b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.jsx index 4f20e92301914..33870b03ead36 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.jsx +++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.jsx @@ -19,8 +19,8 @@ /* eslint-disable react/no-array-index-key */ import PropTypes from 'prop-types'; import React from 'react'; +import { styled } from '@superset-ui/core'; import TTestTable, { dataPropType } from './TTestTable'; -import './PairedTTest.css'; const propTypes = { alpha: PropTypes.number, @@ -39,29 +39,103 @@ const defaultProps = { pValPrec: 6, }; +const StyledDiv = styled.div` + ${({ theme }) => ` + .superset-legacy-chart-paired_ttest .scrollbar-container { + overflow: auto; + } + + .paired-ttest-table .scrollbar-content { + padding-left: ${theme.gridUnit}px; + padding-right: ${theme.gridUnit}px; + margin-bottom: 0; + } + + .paired-ttest-table table { + margin-bottom: 0; + } + + .paired-ttest-table h1 { + margin-left: ${theme.gridUnit}px; + } + + .reactable-data tr, + .reactable-header-sortable { + -webkit-transition: ease-in-out 0.1s; + transition: ease-in-out 0.1s; + } + + .reactable-data tr:hover { + background-color: ${theme.colors.grayscale.light3}; + } + + .reactable-data tr .false { + color: ${theme.colors.error.base}; + } + + .reactable-data tr .true { + color: ${theme.colors.success.base}; + } + + .reactable-data tr .control { + color: ${theme.colors.primary.base}; + } + + .reactable-data tr .invalid { + color: ${theme.colors.warning.base}; + } + + .reactable-data .control td { + background-color: ${theme.colors.grayscale.light3}; + } + + .reactable-header-sortable:hover, + .reactable-header-sortable:focus, + .reactable-header-sort-asc, + .reactable-header-sort-desc { + background-color: ${theme.colors.grayscale.light3}; + position: relative; + } + + .reactable-header-sort-asc:after { + content: '\\25bc'; + position: absolute; + right: ${theme.gridUnit * 3}px; + } + + .reactable-header-sort-desc:after { + content: '\\25b2'; + position: absolute; + right: ${theme.gridUnit * 3}px; + } + `} +`; + class PairedTTest extends React.PureComponent { render() { const { className, metrics, groups, data, alpha, pValPrec, liftValPrec } = this.props; return ( -
-
-
- {metrics.map((metric, i) => ( - - ))} + +
+
+
+ {metrics.map((metric, i) => ( + + ))} +
-
+ ); } } diff --git a/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/src/ParallelCoordinates.js b/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/src/ParallelCoordinates.js index 61d151d1f3e8d..0529c86aff10d 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/src/ParallelCoordinates.js +++ b/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/src/ParallelCoordinates.js @@ -23,7 +23,6 @@ import { getSequentialSchemeRegistry } from '@superset-ui/core'; import parcoords from './vendor/parcoords/d3.parcoords'; import divgrid from './vendor/parcoords/divgrid'; -import './vendor/parcoords/d3.parcoords.css'; const propTypes = { // Standard tabular data [{ fieldName1: value1, fieldName2: value2 }] diff --git a/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/src/ReactParallelCoordinates.jsx b/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/src/ReactParallelCoordinates.jsx index 8633607984952..712509e4eb0df 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/src/ReactParallelCoordinates.jsx +++ b/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/src/ReactParallelCoordinates.jsx @@ -17,7 +17,7 @@ * under the License. */ import React from 'react'; -import { styled, reactify } from '@superset-ui/core'; +import { styled, reactify, addAlpha } from '@superset-ui/core'; import PropTypes from 'prop-types'; import Component from './ParallelCoordinates'; @@ -34,14 +34,93 @@ ParallelCoordianes.propTypes = { }; export default styled(ParallelCoordianes)` - .superset-legacy-chart-parallel-coordinates { - div.grid { - overflow: auto; - div.row { - &:hover { - background-color: #ccc; + ${({ theme }) => ` + .superset-legacy-chart-parallel-coordinates { + div.grid { + overflow: auto; + div.row { + &:hover { + background-color: ${theme.colors.grayscale.light2}; + } } } } - } + .parcoords svg, + .parcoords canvas { + font-size: ${theme.typography.sizes.s}px; + position: absolute; + } + .parcoords > canvas { + pointer-events: none; + } + + .parcoords text.label { + font: 100%; + font-size: ${theme.typography.sizes.s}px; + cursor: drag; + } + .parcoords rect.background { + fill: transparent; + } + .parcoords rect.background:hover { + fill: ${addAlpha(theme.colors.grayscale.base, 0.2)}; + } + .parcoords .resize rect { + fill: ${addAlpha(theme.colors.grayscale.dark2, 0.1)}; + } + .parcoords rect.extent { + fill: ${addAlpha(theme.colors.grayscale.light5, 0.25)}; + stroke: ${addAlpha(theme.colors.grayscale.dark2, 0.6)}; + } + .parcoords .axis line, + .parcoords .axis path { + fill: none; + stroke: ${theme.colors.grayscale.dark1}; + shape-rendering: crispEdges; + } + .parcoords canvas { + opacity: 1; + -moz-transition: opacity 0.3s; + -webkit-transition: opacity 0.3s; + -o-transition: opacity 0.3s; + } + .parcoords canvas.faded { + opacity: ${theme.opacity.mediumLight}; + } + .parcoords { + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background-color: ${theme.colors.grayscale.light5}; + } + + /* data table styles */ + .parcoords .row, + .parcoords .header { + clear: left; + font-size: ${theme.typography.sizes.s}px; + line-height: 18px; + height: 18px; + margin: 0px; + } + .parcoords .row:nth-child(odd) { + background: ${addAlpha(theme.colors.grayscale.dark2, 0.05)}; + } + .parcoords .header { + font-weight: ${theme.typography.weights.bold}; + } + .parcoords .cell { + float: left; + overflow: hidden; + white-space: nowrap; + width: 100px; + height: 18px; + } + .parcoords .col-0 { + width: 180px; + } + `} `; diff --git a/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/src/vendor/parcoords/d3.parcoords.css b/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/src/vendor/parcoords/d3.parcoords.css deleted file mode 100644 index cc82bf94080b3..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/src/vendor/parcoords/d3.parcoords.css +++ /dev/null @@ -1,79 +0,0 @@ -/* [LICENSE TBD] */ -.parcoords svg, -.parcoords canvas { - font-size: 12px; - position: absolute; -} -.parcoords > canvas { - pointer-events: none; -} - -.parcoords text.label { - font: 100%; - font-size: 12px; - cursor: drag; -} - -.parcoords rect.background { - fill: transparent; -} -.parcoords rect.background:hover { - fill: rgba(120, 120, 120, 0.2); -} -.parcoords .resize rect { - fill: rgba(0, 0, 0, 0.1); -} -.parcoords rect.extent { - fill: rgba(255, 255, 255, 0.25); - stroke: rgba(0, 0, 0, 0.6); -} -.parcoords .axis line, -.parcoords .axis path { - fill: none; - stroke: #222; - shape-rendering: crispEdges; -} -.parcoords canvas { - opacity: 1; - -moz-transition: opacity 0.3s; - -webkit-transition: opacity 0.3s; - -o-transition: opacity 0.3s; -} -.parcoords canvas.faded { - opacity: 0.25; -} -.parcoords { - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - background-color: white; -} - -/* data table styles */ -.parcoords .row, -.parcoords .header { - clear: left; - font-size: 12px; - line-height: 18px; - height: 18px; - margin: 0px; -} -.parcoords .row:nth-child(odd) { - background: rgba(0, 0, 0, 0.05); -} -.parcoords .header { - font-weight: bold; -} -.parcoords .cell { - float: left; - overflow: hidden; - white-space: nowrap; - width: 100px; - height: 18px; -} -.parcoords .col-0 { - width: 180px; -} diff --git a/superset-frontend/plugins/legacy-plugin-chart-partition/package.json b/superset-frontend/plugins/legacy-plugin-chart-partition/package.json index 647a730fed3fc..2384214fd9fe8 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-partition/package.json +++ b/superset-frontend/plugins/legacy-plugin-chart-partition/package.json @@ -2,31 +2,25 @@ "name": "@superset-ui/legacy-plugin-chart-partition", "version": "0.18.25", "description": "Superset Legacy Chart - Partition", - "sideEffects": [ - "*.css" - ], - "main": "lib/index.js", - "module": "esm/index.js", - "files": [ - "esm", - "lib" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/apache-superset/superset-ui.git" - }, "keywords": [ "superset" ], - "author": "Superset", - "license": "Apache-2.0", + "homepage": "https://github.com/apache-superset/superset-ui#readme", "bugs": { "url": "https://github.com/apache-superset/superset-ui/issues" }, - "homepage": "https://github.com/apache-superset/superset-ui#readme", - "publishConfig": { - "access": "public" + "repository": { + "type": "git", + "url": "git+https://github.com/apache-superset/superset-ui.git" }, + "license": "Apache-2.0", + "author": "Superset", + "main": "lib/index.js", + "module": "esm/index.js", + "files": [ + "esm", + "lib" + ], "dependencies": { "d3": "^3.5.17", "d3-hierarchy": "^1.1.8", @@ -35,7 +29,10 @@ "peerDependencies": { "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", - "react": "^16.13.1", - "enzyme": "*" + "enzyme": "*", + "react": "^16.13.1" + }, + "publishConfig": { + "access": "public" } } diff --git a/superset-frontend/plugins/legacy-plugin-chart-partition/src/Partition.css b/superset-frontend/plugins/legacy-plugin-chart-partition/src/Partition.css deleted file mode 100644 index 4fce2089b93ff..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-partition/src/Partition.css +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -.superset-legacy-chart-partition { - position: relative; -} - -.superset-legacy-chart-partition .chart { - display: block; - margin: auto; - font-size: 11px; -} - -.superset-legacy-chart-partition rect { - stroke: #eee; - fill: #aaa; - fill-opacity: 0.8; - transition: fill-opacity 180ms linear; - cursor: pointer; -} - -.superset-legacy-chart-partition rect:hover { - fill-opacity: 1; -} - -.superset-legacy-chart-partition g text { - font-weight: bold; - fill: rgba(0, 0, 0, 0.8); -} - -.superset-legacy-chart-partition g:hover text { - fill: rgba(0, 0, 0, 1); -} - -.superset-legacy-chart-partition .partition-tooltip { - position: absolute; - top: 0; - left: 0; - opacity: 0; - padding: 5px; - pointer-events: none; - background-color: rgba(255, 255, 255, 0.75); - border-radius: 5px; -} - -.partition-tooltip td { - padding-left: 5px; - font-size: 11px; -} diff --git a/superset-frontend/plugins/legacy-plugin-chart-partition/src/Partition.js b/superset-frontend/plugins/legacy-plugin-chart-partition/src/Partition.js index 5355530cd536a..22470616680bc 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-partition/src/Partition.js +++ b/superset-frontend/plugins/legacy-plugin-chart-partition/src/Partition.js @@ -26,7 +26,6 @@ import { getTimeFormatter, CategoricalColorNamespace, } from '@superset-ui/core'; -import './Partition.css'; // Compute dx, dy, x, y for each node and // return an array of nodes in breadth-first order @@ -268,13 +267,12 @@ function Icicle(element, props) { if (useRichTooltip) { const nodes = getAncestors(d); nodes.reverse().forEach(n => { - const atNode = n.depth === d.depth; t += ''; t += '' + '' + '
' + '' + diff --git a/superset-frontend/plugins/legacy-plugin-chart-partition/src/ReactPartition.js b/superset-frontend/plugins/legacy-plugin-chart-partition/src/ReactPartition.js deleted file mode 100644 index 22d61a6c88e71..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-partition/src/ReactPartition.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { reactify } from '@superset-ui/core'; -import Component from './Partition'; - -export default reactify(Component); diff --git a/superset-frontend/plugins/legacy-plugin-chart-partition/src/ReactPartition.jsx b/superset-frontend/plugins/legacy-plugin-chart-partition/src/ReactPartition.jsx new file mode 100644 index 0000000000000..d73476ac58f9f --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-partition/src/ReactPartition.jsx @@ -0,0 +1,81 @@ +/** + * 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 React from 'react'; +import { reactify, styled } from '@superset-ui/core'; +import Component from './Partition'; + +const ReactComponent = reactify(Component); + +const Partition = ({ className, ...otherProps }) => ( +
+ +
+); + +export default styled(Partition)` + ${({ theme }) => ` + .superset-legacy-chart-partition { + position: relative; + } + + .superset-legacy-chart-partition .chart { + display: block; + margin: auto; + font-size: ${theme.typography.sizes.s}px; + } + + .superset-legacy-chart-partition rect { + stroke: ${theme.colors.grayscale.light2}; + fill: ${theme.colors.grayscale.light1}; + fill-opacity: ${theme.opacity.heavy}; + transition: fill-opacity 180ms linear; + cursor: pointer; + } + + .superset-legacy-chart-partition rect:hover { + fill-opacity: 1; + } + + .superset-legacy-chart-partition g text { + font-weight: ${theme.typography.weights.bold}; + fill: ${theme.colors.grayscale.dark1}; + } + + .superset-legacy-chart-partition g:hover text { + fill: ${theme.colors.grayscale.dark2}; + } + + .superset-legacy-chart-partition .partition-tooltip { + position: absolute; + top: 0; + left: 0; + opacity: 0; + padding: ${theme.gridUnit}px; + pointer-events: none; + background-color: ${theme.colors.grayscale.dark2}; + border-radius: ${theme.gridUnit}px; + } + + .partition-tooltip td { + padding-left: ${theme.gridUnit}px; + font-size: ${theme.typography.sizes.s}px; + color: ${theme.colors.grayscale.light5}; + } + `} +`; diff --git a/superset-frontend/plugins/legacy-plugin-chart-partition/src/controlPanel.tsx b/superset-frontend/plugins/legacy-plugin-chart-partition/src/controlPanel.tsx index c742e6d1335cb..93139f7ff7b8d 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-partition/src/controlPanel.tsx +++ b/superset-frontend/plugins/legacy-plugin-chart-partition/src/controlPanel.tsx @@ -240,7 +240,7 @@ const config: ControlPanelConfig = { ), controlSetRows: [ // eslint-disable-next-line react/jsx-key - [

{t('Rolling Window')}

], + [
{t('Rolling Window')}
], [ { name: 'rolling_type', @@ -292,7 +292,7 @@ const config: ControlPanelConfig = { }, ], // eslint-disable-next-line react/jsx-key - [

{t('Time Comparison')}

], + [
{t('Time Comparison')}
], [ { name: 'time_compare', @@ -341,10 +341,7 @@ const config: ControlPanelConfig = { }, }, ], - // eslint-disable-next-line react/jsx-key - [

{t('Python Functions')}

], - // eslint-disable-next-line react/jsx-key - [

pandas.resample

], + [
{t('Resample')}
], [ { name: 'resample_rule', diff --git a/superset-frontend/plugins/legacy-plugin-chart-rose/package.json b/superset-frontend/plugins/legacy-plugin-chart-rose/package.json index 1541f323fe418..20deaa3bdb4d0 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-rose/package.json +++ b/superset-frontend/plugins/legacy-plugin-chart-rose/package.json @@ -2,39 +2,37 @@ "name": "@superset-ui/legacy-plugin-chart-rose", "version": "0.18.25", "description": "Superset Legacy Chart - Nightingale Rose Diagram", - "sideEffects": [ - "*.css" - ], - "main": "lib/index.js", - "module": "esm/index.js", - "files": [ - "esm", - "lib" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/apache-superset/superset-ui.git" - }, "keywords": [ "superset" ], - "author": "Superset", - "license": "Apache-2.0", + "homepage": "https://github.com/apache-superset/superset-ui#readme", "bugs": { "url": "https://github.com/apache-superset/superset-ui/issues" }, - "homepage": "https://github.com/apache-superset/superset-ui#readme", - "publishConfig": { - "access": "public" + "repository": { + "type": "git", + "url": "git+https://github.com/apache-superset/superset-ui.git" }, + "license": "Apache-2.0", + "author": "Superset", + "main": "lib/index.js", + "module": "esm/index.js", + "files": [ + "esm", + "lib" + ], "dependencies": { "d3": "^3.5.17", "nvd3-fork": "^2.0.5", "prop-types": "^15.6.2" }, "peerDependencies": { + "@emotion/react": "^11.4.1", "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", "react": "^16.13.1" + }, + "publishConfig": { + "access": "public" } } diff --git a/superset-frontend/plugins/legacy-plugin-chart-rose/src/ReactRose.js b/superset-frontend/plugins/legacy-plugin-chart-rose/src/ReactRose.js deleted file mode 100644 index bfdd152460689..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-rose/src/ReactRose.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { reactify } from '@superset-ui/core'; -import Component from './Rose'; - -export default reactify(Component); diff --git a/superset-frontend/plugins/legacy-plugin-chart-rose/src/ReactRose.jsx b/superset-frontend/plugins/legacy-plugin-chart-rose/src/ReactRose.jsx new file mode 100644 index 0000000000000..33d9cd4f9c8fd --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-rose/src/ReactRose.jsx @@ -0,0 +1,75 @@ +/** + * 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 React from 'react'; +import { reactify, styled, css } from '@superset-ui/core'; +import { Global } from '@emotion/react'; +import Component from './Rose'; + +const ReactComponent = reactify(Component); + +const Rose = ({ className, ...otherProps }) => ( +
+ css` + .tooltip { + line-height: 1; + padding: ${theme.gridUnit * 3}px; + background: ${theme.colors.grayscale.dark2}; + color: ${theme.colors.grayscale.light5}; + border-radius: 4px; + pointer-events: none; + z-index: 1000; + font-size: ${theme.typography.sizes.s}px; + } + `} + /> + +
+); + +export default styled(Rose)` + ${({ theme }) => ` + .superset-legacy-chart-rose path { + transition: fill-opacity 180ms linear; + stroke: ${theme.colors.grayscale.light5}; + stroke-width: 1px; + stroke-opacity: 1; + fill-opacity: 0.75; + } + + .superset-legacy-chart-rose text { + font-weight: ${theme.typography.weights.normal}; + font-size: ${theme.typography.sizes.s}px; + font-family: ${theme.typography.families.sansSerif}; + pointer-events: none; + } + + .superset-legacy-chart-rose .clickable path { + cursor: pointer; + } + + .superset-legacy-chart-rose .hover path { + fill-opacity: 1; + } + + .nv-legend .nv-series { + cursor: pointer; + } + `} +`; diff --git a/superset-frontend/plugins/legacy-plugin-chart-rose/src/Rose.css b/superset-frontend/plugins/legacy-plugin-chart-rose/src/Rose.css deleted file mode 100644 index 441e95c5d4cc0..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-rose/src/Rose.css +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -.superset-legacy-chart-rose path { - transition: fill-opacity 180ms linear; - stroke: #fff; - stroke-width: 1px; - stroke-opacity: 1; - fill-opacity: 0.75; -} - -.superset-legacy-chart-rose text { - font: 400 12px Arial, sans-serif; - pointer-events: none; -} - -.superset-legacy-chart-rose .clickable path { - cursor: pointer; -} - -.superset-legacy-chart-rose .hover path { - fill-opacity: 1; -} - -.nv-legend .nv-series { - cursor: pointer; -} diff --git a/superset-frontend/plugins/legacy-plugin-chart-rose/src/Rose.js b/superset-frontend/plugins/legacy-plugin-chart-rose/src/Rose.js index 4d7ef2b8ed995..2dfa2ffdd70a2 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-rose/src/Rose.js +++ b/superset-frontend/plugins/legacy-plugin-chart-rose/src/Rose.js @@ -27,7 +27,6 @@ import { getNumberFormatter, CategoricalColorNamespace, } from '@superset-ui/core'; -import './Rose.css'; const propTypes = { // Data is an object hashed by numeric value, perhaps timestamp @@ -137,6 +136,7 @@ function Rose(element, props) { legendWrap.datum(legendData(datum)).call(legend); tooltip.headerFormatter(timeFormat).valueFormatter(format); + tooltip.classes('tooltip'); // Compute max radius, which the largest value will occupy const roseHeight = height - legend.height(); diff --git a/superset-frontend/plugins/legacy-plugin-chart-rose/src/controlPanel.tsx b/superset-frontend/plugins/legacy-plugin-chart-rose/src/controlPanel.tsx index fd04117e6217c..e43da2de7237a 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-rose/src/controlPanel.tsx +++ b/superset-frontend/plugins/legacy-plugin-chart-rose/src/controlPanel.tsx @@ -123,7 +123,7 @@ const config: ControlPanelConfig = { ), controlSetRows: [ // eslint-disable-next-line react/jsx-key - [

{t('Rolling Window')}

], + [
{t('Rolling Window')}
], [ { name: 'rolling_type', @@ -175,7 +175,7 @@ const config: ControlPanelConfig = { }, ], // eslint-disable-next-line react/jsx-key - [

{t('Time Comparison')}

], + [
{t('Time Comparison')}
], [ { name: 'time_compare', @@ -224,10 +224,7 @@ const config: ControlPanelConfig = { }, }, ], - // eslint-disable-next-line react/jsx-key - [

{t('Python Functions')}

], - // eslint-disable-next-line react/jsx-key - [

pandas.resample

], + [
{t('Resample')}
], [ { name: 'resample_rule', diff --git a/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/package.json b/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/package.json index 3810ff1e45886..247e408b772ff 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/package.json +++ b/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/package.json @@ -2,31 +2,25 @@ "name": "@superset-ui/legacy-plugin-chart-sankey-loop", "version": "0.18.25", "description": "Superset Legacy Chart - Sankey Diagram with Loops", - "sideEffects": [ - "*.css" - ], - "main": "lib/index.js", - "module": "esm/index.js", - "files": [ - "esm", - "lib" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/apache-superset/superset-ui.git" - }, "keywords": [ "superset" ], - "author": "Superset", - "license": "Apache-2.0", + "homepage": "https://github.com/apache-superset/superset-ui#readme", "bugs": { "url": "https://github.com/apache-superset/superset-ui/issues" }, - "homepage": "https://github.com/apache-superset/superset-ui#readme", - "publishConfig": { - "access": "public" + "repository": { + "type": "git", + "url": "git+https://github.com/apache-superset/superset-ui.git" }, + "license": "Apache-2.0", + "author": "Superset", + "main": "lib/index.js", + "module": "esm/index.js", + "files": [ + "esm", + "lib" + ], "dependencies": { "d3-sankey-diagram": "^0.7.3", "d3-selection": "^1.4.0", @@ -34,6 +28,10 @@ }, "peerDependencies": { "@superset-ui/chart-controls": "*", - "@superset-ui/core": "*" + "@superset-ui/core": "*", + "react": "^16.13.1" + }, + "publishConfig": { + "access": "public" } } diff --git a/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/src/ReactSankeyLoop.js b/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/src/ReactSankeyLoop.js deleted file mode 100644 index 034f97588c239..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/src/ReactSankeyLoop.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { reactify } from '@superset-ui/core'; -import Component from './SankeyLoop'; - -export default reactify(Component); diff --git a/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/src/ReactSankeyLoop.jsx b/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/src/ReactSankeyLoop.jsx new file mode 100644 index 0000000000000..4f72433ac9eaa --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/src/ReactSankeyLoop.jsx @@ -0,0 +1,72 @@ +/** + * 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 React from 'react'; +import { reactify, styled } from '@superset-ui/core'; +import Component from './SankeyLoop'; + +const ReactComponent = reactify(Component); + +const Sankey = ({ className, ...otherProps }) => ( +
+ +
+); + +export default styled(Sankey)` + ${({ theme }) => ` + .superset-legacy-chart-sankey-loop .node rect { + cursor: move; + fill-opacity: ${theme.opacity.heavy}; + shape-rendering: crispEdges; + } + + .superset-legacy-chart-sankey-loop .node text { + pointer-events: none; + text-shadow: 0 1px 0 ${theme.colors.grayscale.light5}; + } + + .superset-legacy-chart-sankey-loop .link { + fill: none; + stroke: ${theme.colors.grayscale.dark2}; + stroke-opacity: ${theme.opacity.light}; + } + + .superset-legacy-chart-sankey-loop .link:hover { + stroke-opacity: ${theme.opacity.mediumHeavy}; + } + + .superset-legacy-chart-sankey-loop .link path { + opacity: ${theme.opacity.mediumLight}; + stroke-opacity: 0; + } + + .superset-legacy-chart-sankey-loop .link:hover path { + opacity: ${theme.opacity.heavy}; + } + + .superset-legacy-chart-sankey-loop .link text { + fill: ${theme.colors.grayscale.base}; + font-size: ${theme.gridUnit * 3}px; + } + + .superset-legacy-chart-sankey-loop .link:hover text { + opacity: 1; + } + `} +`; diff --git a/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/src/SankeyLoop.css b/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/src/SankeyLoop.css deleted file mode 100644 index 0cd18e91021ef..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/src/SankeyLoop.css +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -.superset-legacy-chart-sankey-loop .node rect { - cursor: move; - fill-opacity: 0.9; - shape-rendering: crispEdges; -} - -.superset-legacy-chart-sankey-loop .node text { - pointer-events: none; - text-shadow: 0 1px 0 #fff; -} - -.superset-legacy-chart-sankey-loop .link { - fill: none; - stroke: #000; - stroke-opacity: 0.2; -} - -.superset-legacy-chart-sankey-loop .link:hover { - stroke-opacity: 0.5; -} - -.superset-legacy-chart-sankey-loop .link path { - opacity: 0.2; - stroke-opacity: 0; -} - -.superset-legacy-chart-sankey-loop .link:hover path { - opacity: 0.5; -} - -.superset-legacy-chart-sankey-loop .link text { - fill: #666; - font-size: 10px; -} - -.superset-legacy-chart-sankey-loop .link:hover text { - opacity: 1; -} diff --git a/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/src/SankeyLoop.js b/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/src/SankeyLoop.js index 4d6f059fdeb10..33a3490159615 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/src/SankeyLoop.js +++ b/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/src/SankeyLoop.js @@ -26,8 +26,6 @@ import { CategoricalColorNamespace, } from '@superset-ui/core'; -import './SankeyLoop.css'; - // a problem with 'd3-sankey-diagram' is that the sankey().extent() paramters, which // informs the layout of the bounding box of the sankey columns, does not account // for labels and paths which happen to be layedout outside that rectangle. diff --git a/superset-frontend/plugins/legacy-plugin-chart-sankey/src/ReactSankey.jsx b/superset-frontend/plugins/legacy-plugin-chart-sankey/src/ReactSankey.jsx index 75c1ed557ce5c..fe5c5bdea662d 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-sankey/src/ReactSankey.jsx +++ b/superset-frontend/plugins/legacy-plugin-chart-sankey/src/ReactSankey.jsx @@ -34,40 +34,42 @@ SankeyComponent.propTypes = { }; export default styled(SankeyComponent)` - .superset-legacy-chart-sankey { - .node { - rect { - cursor: move; - fill-opacity: 0.9; - shape-rendering: crispEdges; + ${({ theme }) => ` + .superset-legacy-chart-sankey { + .node { + rect { + cursor: move; + fill-opacity: ${theme.opacity.heavy}; + shape-rendering: crispEdges; + } + text { + pointer-events: none; + text-shadow: 0 1px 0 ${theme.colors.grayscale.light5}; + font-size: ${theme.typography.sizes.s}px; + } } - text { - pointer-events: none; - text-shadow: 0 1px 0 #fff; - font-size: ${({ fontSize }) => fontSize}em; + .link { + fill: none; + stroke: ${theme.colors.grayscale.dark2}; + stroke-opacity: ${theme.opacity.light}; + &:hover { + stroke-opacity: ${theme.opacity.mediumLight}; + } } - } - .link { - fill: none; - stroke: #000; - stroke-opacity: 0.2; - &:hover { - stroke-opacity: 0.5; + .opacity-0 { + opacity: 0; } } - .opacity-0 { - opacity: 0; + .sankey-tooltip { + position: absolute; + width: auto; + background: ${theme.colors.grayscale.light2}; + padding: ${theme.gridUnit * 3}px; + font-size: ${theme.typography.sizes.s}px; + color: ${theme.colors.grayscale.dark2}; + border: 1px solid ${theme.colors.grayscale.light5}; + text-align: center; + pointer-events: none; } - } - .sankey-tooltip { - position: absolute; - width: auto; - background: #ddd; - padding: 10px; - font-size: ${({ fontSize }) => fontSize}em; - color: #000; - border: 1px solid #fff; - text-align: center; - pointer-events: none; - } + `} `; diff --git a/superset-frontend/plugins/legacy-plugin-chart-sunburst/package.json b/superset-frontend/plugins/legacy-plugin-chart-sunburst/package.json index db840722fe47a..cdba4664f47e7 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-sunburst/package.json +++ b/superset-frontend/plugins/legacy-plugin-chart-sunburst/package.json @@ -2,37 +2,35 @@ "name": "@superset-ui/legacy-plugin-chart-sunburst", "version": "0.18.25", "description": "Superset Legacy Chart - Sunburst", - "sideEffects": [ - "*.css" - ], - "main": "lib/index.js", - "module": "esm/index.js", - "files": [ - "esm", - "lib" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/apache-superset/superset-ui.git" - }, "keywords": [ "superset" ], - "author": "Superset", - "license": "Apache-2.0", + "homepage": "https://github.com/apache-superset/superset-ui#readme", "bugs": { "url": "https://github.com/apache-superset/superset-ui/issues" }, - "homepage": "https://github.com/apache-superset/superset-ui#readme", - "publishConfig": { - "access": "public" + "repository": { + "type": "git", + "url": "git+https://github.com/apache-superset/superset-ui.git" }, + "license": "Apache-2.0", + "author": "Superset", + "main": "lib/index.js", + "module": "esm/index.js", + "files": [ + "esm", + "lib" + ], "dependencies": { "d3": "^3.5.17", "prop-types": "^15.6.2" }, "peerDependencies": { "@superset-ui/chart-controls": "*", - "@superset-ui/core": "*" + "@superset-ui/core": "*", + "react": "^16.13.1" + }, + "publishConfig": { + "access": "public" } } diff --git a/superset-frontend/plugins/legacy-plugin-chart-sunburst/src/ReactSunburst.js b/superset-frontend/plugins/legacy-plugin-chart-sunburst/src/ReactSunburst.js deleted file mode 100644 index 15303c9eafb62..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-sunburst/src/ReactSunburst.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { reactify } from '@superset-ui/core'; -import Component from './Sunburst'; - -export default reactify(Component); diff --git a/superset-frontend/plugins/legacy-plugin-chart-sunburst/src/ReactSunburst.jsx b/superset-frontend/plugins/legacy-plugin-chart-sunburst/src/ReactSunburst.jsx new file mode 100644 index 0000000000000..10e959285bb4b --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-sunburst/src/ReactSunburst.jsx @@ -0,0 +1,66 @@ +/** + * 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 React from 'react'; +import { reactify, styled } from '@superset-ui/core'; +import Component from './Sunburst'; + +const ReactComponent = reactify(Component); + +const Sunburst = ({ className, ...otherProps }) => ( +
+ +
+); + +export default styled(Sunburst)` + ${({ theme }) => ` + .superset-legacy-chart-sunburst text { + text-rendering: optimizeLegibility; + } + .superset-legacy-chart-sunburst path { + stroke: ${theme.colors.grayscale.light2}; + stroke-width: 0.5px; + } + .superset-legacy-chart-sunburst .center-label { + text-anchor: middle; + fill: ${theme.colors.grayscale.dark1}; + pointer-events: none; + } + .superset-legacy-chart-sunburst .path-abs-percent { + font-size: ${theme.typography.sizes.m}px; + font-weight: ${theme.typography.weights.bold}; + } + .superset-legacy-chart-sunburst .path-cond-percent { + font-size: ${theme.typography.sizes.s}px; + } + .superset-legacy-chart-sunburst .path-metrics { + color: ${theme.colors.grayscale.base}; + } + .superset-legacy-chart-sunburst .path-ratio { + color: ${theme.colors.grayscale.base}; + } + + .superset-legacy-chart-sunburst .breadcrumbs text { + font-weight: ${theme.typography.weights.bold}; + font-size: ${theme.typography.sizes.m}px; + text-anchor: middle; + fill: ${theme.colors.grayscale.dark1}; + } + `} +`; diff --git a/superset-frontend/plugins/legacy-plugin-chart-sunburst/src/Sunburst.css b/superset-frontend/plugins/legacy-plugin-chart-sunburst/src/Sunburst.css deleted file mode 100644 index 0afe0a87951cc..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-sunburst/src/Sunburst.css +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -.superset-legacy-chart-sunburst text { - text-rendering: optimizeLegibility; -} -.superset-legacy-chart-sunburst path { - stroke: #ddd; - stroke-width: 0.5px; -} -.superset-legacy-chart-sunburst .center-label { - text-anchor: middle; - fill: #333; - pointer-events: none; -} -.superset-legacy-chart-sunburst .path-abs-percent { - font-size: 3em; - font-weight: 700; -} -.superset-legacy-chart-sunburst .path-cond-percent { - font-size: 2em; -} -.superset-legacy-chart-sunburst .path-metrics { - color: #777; -} -.superset-legacy-chart-sunburst .path-ratio { - color: #777; -} - -.superset-legacy-chart-sunburst .breadcrumbs text { - font-weight: 600; - font-size: 1.2em; - text-anchor: middle; - fill: #333; -} - -/* dashboard specific */ -.dashboard-chart.sunburst { - overflow: visible; -} -.superset-legacy-chart-sunburst svg { - overflow: visible; -} -.superset-legacy-chart-sunburst.m text { - font-size: 0.55em; -} -.superset-legacy-chart-sunburst.s text { - font-size: 0.45em; -} -.superset-legacy-chart-sunburst.l text { - font-size: 0.75em; -} -.superset-legacy-chart-sunburst .path-abs-percent { - font-weight: 700; -} diff --git a/superset-frontend/plugins/legacy-plugin-chart-sunburst/src/Sunburst.js b/superset-frontend/plugins/legacy-plugin-chart-sunburst/src/Sunburst.js index 2a9cc56f51fc6..4418f68bbd150 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-sunburst/src/Sunburst.js +++ b/superset-frontend/plugins/legacy-plugin-chart-sunburst/src/Sunburst.js @@ -26,7 +26,6 @@ import { getSequentialSchemeRegistry, } from '@superset-ui/core'; import wrapSvgText from './utils/wrapSvgText'; -import './Sunburst.css'; const propTypes = { // Each row is an array of [hierarchy-lvl1, hierarchy-lvl2, metric1, metric2] diff --git a/superset-frontend/plugins/legacy-plugin-chart-treemap/package.json b/superset-frontend/plugins/legacy-plugin-chart-treemap/package.json index afd55b88c18a4..96d582d7859cb 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-treemap/package.json +++ b/superset-frontend/plugins/legacy-plugin-chart-treemap/package.json @@ -2,31 +2,25 @@ "name": "@superset-ui/legacy-plugin-chart-treemap", "version": "0.18.25", "description": "Superset Legacy Chart - Treemap", - "sideEffects": [ - "*.css" - ], - "main": "lib/index.js", - "module": "esm/index.js", - "files": [ - "esm", - "lib" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/apache-superset/superset-ui.git" - }, "keywords": [ "superset" ], - "author": "Superset", - "license": "Apache-2.0", + "homepage": "https://github.com/apache-superset/superset-ui#readme", "bugs": { "url": "https://github.com/apache-superset/superset-ui/issues" }, - "homepage": "https://github.com/apache-superset/superset-ui#readme", - "publishConfig": { - "access": "public" + "repository": { + "type": "git", + "url": "git+https://github.com/apache-superset/superset-ui.git" }, + "license": "Apache-2.0", + "author": "Superset", + "main": "lib/index.js", + "module": "esm/index.js", + "files": [ + "esm", + "lib" + ], "dependencies": { "d3-hierarchy": "^1.1.8", "d3-selection": "^1.4.0", @@ -34,6 +28,10 @@ }, "peerDependencies": { "@superset-ui/chart-controls": "*", - "@superset-ui/core": "*" + "@superset-ui/core": "*", + "react": "^16.13.1" + }, + "publishConfig": { + "access": "public" } } diff --git a/superset-frontend/plugins/legacy-plugin-chart-treemap/src/ReactTreemap.js b/superset-frontend/plugins/legacy-plugin-chart-treemap/src/ReactTreemap.js deleted file mode 100644 index 743115c018798..0000000000000 --- a/superset-frontend/plugins/legacy-plugin-chart-treemap/src/ReactTreemap.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { reactify } from '@superset-ui/core'; -import Component from './Treemap'; - -export default reactify(Component); diff --git a/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/ReactForceDirected.jsx b/superset-frontend/plugins/legacy-plugin-chart-treemap/src/ReactTreemap.jsx similarity index 65% rename from superset-frontend/plugins/legacy-plugin-chart-force-directed/src/ReactForceDirected.jsx rename to superset-frontend/plugins/legacy-plugin-chart-treemap/src/ReactTreemap.jsx index 90088cbcd56ed..c00d8b5d17492 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/ReactForceDirected.jsx +++ b/superset-frontend/plugins/legacy-plugin-chart-treemap/src/ReactTreemap.jsx @@ -18,39 +18,34 @@ */ import React from 'react'; import { reactify, styled } from '@superset-ui/core'; -import PropTypes from 'prop-types'; -import Component from './ForceDirected'; +import Component from './Treemap'; const ReactComponent = reactify(Component); -const ForceDirected = ({ className, ...otherProps }) => ( +const Treemap = ({ className, ...otherProps }) => (
); -ForceDirected.propTypes = { - className: PropTypes.string.isRequired, -}; +export default styled(Treemap)` + ${({ theme }) => ` + .superset-legacy-chart-treemap text { + font-size: ${theme.typography.sizes.s}px; + pointer-events: none; + } -export default styled(ForceDirected)` - .superset-legacy-chart-force-directed { - path.link { - fill: none; - stroke: #000; - stroke-width: 1.5px; + .superset-legacy-chart-treemap tspan:last-child { + font-size: ${theme.typography.sizes.xs}px; + fill-opacity: 0.8; } - circle { - fill: #ccc; - stroke: #000; - stroke-width: 1.5px; - stroke-opacity: 1; - opacity: 0.75; + + .superset-legacy-chart-treemap .node rect { + shape-rendering: crispEdges; } - text { - fill: #000; - font: 10px sans-serif; - pointer-events: none; + + .superset-legacy-chart-treemap .node--hover rect { + stroke: ${theme.colors.grayscale.dark2}; } - } + `} `; diff --git a/superset-frontend/plugins/legacy-plugin-chart-treemap/src/Treemap.js b/superset-frontend/plugins/legacy-plugin-chart-treemap/src/Treemap.js index f218218ec8bbd..e0f4e691220c2 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-treemap/src/Treemap.js +++ b/superset-frontend/plugins/legacy-plugin-chart-treemap/src/Treemap.js @@ -29,7 +29,6 @@ import { getNumberFormatter, CategoricalColorNamespace, } from '@superset-ui/core'; -import './Treemap.css'; // Declare PropTypes for recursive data structures // https://github.com/facebook/react/issues/5676 diff --git a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/ReactWorldMap.jsx b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/ReactWorldMap.jsx index d0f23e1844376..52b20f1bdafab 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/ReactWorldMap.jsx +++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/ReactWorldMap.jsx @@ -37,7 +37,7 @@ export default styled(WorldMapComponent)` .superset-legacy-chart-world-map { position: relative; svg { - background-color: #feffff; + background-color: ${({ theme }) => theme.colors.grayscale.light5}; } } `; diff --git a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.js b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.js index 0c81e98560166..c7253e10d0e68 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.js +++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.js @@ -23,7 +23,6 @@ import { extent as d3Extent } from 'd3-array'; import { getNumberFormatter, getSequentialSchemeRegistry, - CategoricalColorNamespace, } from '@superset-ui/core'; import Datamap from 'datamaps/dist/datamaps.world.min'; @@ -56,8 +55,6 @@ function WorldMap(element, props) { showBubbles, linearColorScheme, color, - colorScheme, - sliceId, } = props; const div = d3.select(element); div.classed('superset-legacy-chart-world-map', true); @@ -72,24 +69,15 @@ function WorldMap(element, props) { .domain([extRadius[0], extRadius[1]]) .range([1, maxBubbleSize]); - const linearColorScale = getSequentialSchemeRegistry() + const colorScale = getSequentialSchemeRegistry() .get(linearColorScheme) .createLinearScale(d3Extent(filteredData, d => d.m1)); - const colorScale = CategoricalColorNamespace.getScale(colorScheme); - - const processedData = filteredData.map(d => { - let color = linearColorScale(d.m1); - if (colorScheme) { - // use color scheme instead - color = colorScale(d.name, sliceId); - } - return { - ...d, - radius: radiusScale(Math.sqrt(d.m2)), - fillColor: color, - }; - }); + const processedData = filteredData.map(d => ({ + ...d, + radius: radiusScale(Math.sqrt(d.m2)), + fillColor: colorScale(d.m1), + })); const mapData = {}; processedData.forEach(d => { diff --git a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/controlPanel.ts b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/controlPanel.ts index 91664290dcb02..ec8aafc7b872a 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/controlPanel.ts +++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/controlPanel.ts @@ -106,7 +106,6 @@ const config: ControlPanelConfig = { }, ], ['color_picker'], - ['color_scheme'], ['linear_color_scheme'], ], }, @@ -127,9 +126,6 @@ const config: ControlPanelConfig = { color_picker: { label: t('Bubble Color'), }, - color_scheme: { - label: t('Categorical Color Scheme'), - }, linear_color_scheme: { label: t('Country Color Scheme'), }, diff --git a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/transformProps.js b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/transformProps.js index 3838ebfa5c10a..464dd53afa4fc 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/transformProps.js +++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/transformProps.js @@ -20,14 +20,8 @@ import { rgb } from 'd3-color'; export default function transformProps(chartProps) { const { width, height, formData, queriesData } = chartProps; - const { - maxBubbleSize, - showBubbles, - linearColorScheme, - colorPicker, - colorScheme, - sliceId, - } = formData; + const { maxBubbleSize, showBubbles, linearColorScheme, colorPicker } = + formData; const { r, g, b } = colorPicker; return { @@ -38,7 +32,5 @@ export default function transformProps(chartProps) { showBubbles, linearColorScheme, color: rgb(r, g, b).hex(), - colorScheme, - sliceId, }; } diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/DeckGLContainer.jsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/DeckGLContainer.jsx index 53c6f73a2e09f..9bc963c9fce3d 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/DeckGLContainer.jsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/DeckGLContainer.jsx @@ -28,7 +28,6 @@ import DeckGL from 'deck.gl'; import { styled } from '@superset-ui/core'; import Tooltip from './components/Tooltip'; import 'mapbox-gl/dist/mapbox-gl.css'; -import './css/deckgl.css'; const TICK = 250; // milliseconds diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/BootstrapSliderWrapper.css b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/BootstrapSliderWrapper.css deleted file mode 100644 index dc54046e4c258..0000000000000 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/BootstrapSliderWrapper.css +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -.BootstrapSliderWrapper .slider-selection { - background: #efefef; -} - -.BootstrapSliderWrapper .slider-handle { - background: #b3b3b3; -} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/BootstrapSliderWrapper.jsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/BootstrapSliderWrapper.jsx index 51698b5dfae04..0ff45f5ea2c54 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/BootstrapSliderWrapper.jsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/BootstrapSliderWrapper.jsx @@ -19,12 +19,23 @@ import React from 'react'; import ReactBootstrapSlider from 'react-bootstrap-slider'; import 'bootstrap-slider/dist/css/bootstrap-slider.min.css'; -import './BootstrapSliderWrapper.css'; +import { styled } from '@superset-ui/core'; + +const StyledSlider = styled.div` + ${({ theme }) => ` + .slider-selection { + background: ${theme.colors.grayscale.light2}; + } + .slider-handle { + background: ${theme.colors.grayscale.light1}; + } + `} +`; export default function BootstrapSliderWrapper(props) { return ( - + - + ); } diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Legend.css b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Legend.css deleted file mode 100644 index 706dfaf0cd23b..0000000000000 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Legend.css +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -div.legend { - font-size: 90%; - position: absolute; - background: #fff; - box-shadow: 0 0 4px rgba(0, 0, 0, 0.15); - margin: 24px; - padding: 12px 20px; - outline: none; - overflow-y: scroll; - max-height: 200px; -} - -ul.categories { - list-style: none; - padding-left: 0; - margin: 0; -} - -ul.categories li a { - color: rgb(51, 51, 51); - text-decoration: none; -} - -ul.categories li a span { - margin-right: 10px; -} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Legend.jsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Legend.jsx index bb18626b699f6..40f13bb514d5b 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Legend.jsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Legend.jsx @@ -21,9 +21,36 @@ */ import React from 'react'; import PropTypes from 'prop-types'; -import { formatNumber } from '@superset-ui/core'; - -import './Legend.css'; +import { formatNumber, styled } from '@superset-ui/core'; + +const StyledLegend = styled.div` + ${({ theme }) => ` + font-size: ${theme.typography.sizes.s}px; + position: absolute; + background: ${theme.colors.grayscale.light5}; + box-shadow: 0 0 ${theme.gridUnit}px ${theme.colors.grayscale.light2}; + margin: ${theme.gridUnit * 6}px; + padding: ${theme.gridUnit * 3}px ${theme.gridUnit * 5}px; + outline: none; + overflow-y: scroll; + max-height: 200px; + + & ul { + list-style: none; + padding-left: 0; + margin: 0; + + & li a { + color: ${theme.colors.grayscale.base}; + text-decoration: none; + + & span { + margin-right: ${theme.gridUnit * 3}px; + } + } + } + `} +`; const categoryDelimiter = ' - '; @@ -106,9 +133,9 @@ export default class Legend extends React.PureComponent { }; return ( -
-
    {categories}
-
+ +
    {categories}
+
); } } diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/PlaySlider.css b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/PlaySlider.css deleted file mode 100644 index f4b6f5de2eea1..0000000000000 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/PlaySlider.css +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -.play-slider { - display: flex; - height: 40px; - width: 100%; - margin: 0; -} - -.play-slider-controls { - flex: 0 0 80px; - text-align: middle; -} - -.play-slider-scrobbler { - flex: 1; -} - -.slider.slider-horizontal { - width: 100% !important; -} - -.slider-button { - color: #b3b3b3; - margin-right: 5px; -} - -div.slider > div.tooltip.tooltip-main.top.in { - margin-left: 0 !important; -} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/PlaySlider.jsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/PlaySlider.jsx index d0ec3199d4c81..eda7803f21754 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/PlaySlider.jsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/PlaySlider.jsx @@ -26,9 +26,39 @@ import React from 'react'; import PropTypes from 'prop-types'; import Mousetrap from 'mousetrap'; -import { t } from '@superset-ui/core'; +import { t, styled } from '@superset-ui/core'; import BootrapSliderWrapper from './BootstrapSliderWrapper'; -import './PlaySlider.css'; + +const StyledSlider = styled.div` + ${({ theme }) => ` + display: flex; + height: 40px; + width: 100%; + margin: 0; + + .play-slider-controls { + flex: 0 0 80px; + text-align: middle; + } + + .play-slider-scrobbler { + flex: 1; + } + + .slider.slider-horizontal { + width: 100% !important; + } + + .slider-button { + color: ${theme.colors.grayscale.light1}; + margin-right: ${theme.gridUnit}px; + } + + div.slider > div.tooltip.tooltip-main.top.in { + margin-left: 0 !important; + } + `} +`; const propTypes = { start: PropTypes.number.isRequired, @@ -167,7 +197,7 @@ export default class PlaySlider extends React.PureComponent { this.props; return ( -
+
-
+ ); } } diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/css/deckgl.css b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/css/deckgl.css deleted file mode 100644 index f836c27f08781..0000000000000 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/css/deckgl.css +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -.deckgl-tooltip > div { - overflow: hidden; - text-overflow: ellipsis; -} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/Screengrid.jsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/Screengrid.jsx index 061ccc46de4d1..ca61ec0b81cca 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/Screengrid.jsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/Screengrid.jsx @@ -43,7 +43,7 @@ function setTooltipContent(o) { label={`${t('Longitude and Latitude')}: `} value={`${o.coordinate[0]}, ${o.coordinate[1]}`} /> - +
); } diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utils.js b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utils.js index 5b3bc9dfdf123..4de17a9309b43 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utils.js +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utils.js @@ -48,10 +48,12 @@ export function getBreakPoints( const precision = delta === 0 ? 0 : Math.max(0, Math.ceil(Math.log10(1 / delta))); const extraBucket = maxValue > maxValue.toFixed(precision) ? 1 : 0; + const startValue = + minValue < minValue.toFixed(precision) ? minValue - 1 : minValue; return new Array(numBuckets + 1 + extraBucket) .fill() - .map((_, i) => (minValue + i * delta).toFixed(precision)); + .map((_, i) => (startValue + i * delta).toFixed(precision)); } return formDataBreakPoints.sort((a, b) => parseFloat(a) - parseFloat(b)); diff --git a/superset-frontend/plugins/legacy-preset-chart-nvd3/src/DistBar/controlPanel.ts b/superset-frontend/plugins/legacy-preset-chart-nvd3/src/DistBar/controlPanel.ts index 3df9e00057b1a..278743d472749 100644 --- a/superset-frontend/plugins/legacy-preset-chart-nvd3/src/DistBar/controlPanel.ts +++ b/superset-frontend/plugins/legacy-preset-chart-nvd3/src/DistBar/controlPanel.ts @@ -106,7 +106,7 @@ const config: ControlPanelConfig = { ], controlOverrides: { groupby: { - label: t('Series'), + label: t('Dimensions'), validators: [validateNonEmpty], mapStateToProps: (state, controlState) => { const groupbyProps = diff --git a/superset-frontend/plugins/legacy-preset-chart-nvd3/src/NVD3Controls.tsx b/superset-frontend/plugins/legacy-preset-chart-nvd3/src/NVD3Controls.tsx index 3b0bb92ac758b..151c53e41f2ff 100644 --- a/superset-frontend/plugins/legacy-preset-chart-nvd3/src/NVD3Controls.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-nvd3/src/NVD3Controls.tsx @@ -370,7 +370,7 @@ export const timeSeriesSection: ControlPanelSectionConfig[] = [ 'of query results', ), controlSetRows: [ - [

{t('Rolling Window')}

], + [
{t('Rolling Window')}
], [ { name: 'rolling_type', @@ -423,7 +423,7 @@ export const timeSeriesSection: ControlPanelSectionConfig[] = [ }, }, ], - [

{t('Time Comparison')}

], + [
{t('Time Comparison')}
], [ { name: 'time_compare', @@ -474,8 +474,7 @@ export const timeSeriesSection: ControlPanelSectionConfig[] = [ }, }, ], - [

{t('Python Functions')}

], - [

pandas.resample

], + [
{t('Resample')}
], [ { name: 'resample_rule', diff --git a/superset-frontend/plugins/legacy-preset-chart-nvd3/src/NVD3Vis.js b/superset-frontend/plugins/legacy-preset-chart-nvd3/src/NVD3Vis.js index 4d130d2139d0c..727954b2d0267 100644 --- a/superset-frontend/plugins/legacy-preset-chart-nvd3/src/NVD3Vis.js +++ b/superset-frontend/plugins/legacy-preset-chart-nvd3/src/NVD3Vis.js @@ -434,7 +434,11 @@ function nvd3Vis(element, props) { chart.stacked(isBarStacked); if (orderBars) { data.forEach(d => { - d.values.sort((a, b) => (tryNumify(a.x) < tryNumify(b.x) ? -1 : 1)); + const newValues = [...d.values]; // need to copy values to avoid redux store changed. + // eslint-disable-next-line no-param-reassign + d.values = newValues.sort((a, b) => + tryNumify(a.x) < tryNumify(b.x) ? -1 : 1, + ); }); } if (!reduceXTicks) { diff --git a/superset-frontend/plugins/plugin-chart-echarts/package.json b/superset-frontend/plugins/plugin-chart-echarts/package.json index 8d1e559258b8e..0c5cbcf595ae0 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/package.json +++ b/superset-frontend/plugins/plugin-chart-echarts/package.json @@ -27,7 +27,7 @@ }, "dependencies": { "d3-array": "^1.2.0", - "echarts": "^5.3.1", + "echarts": "^5.3.2", "lodash": "^4.17.15", "moment": "^2.26.0" }, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts index e30dcbe6bee6d..8511c3ca5645e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts @@ -34,7 +34,7 @@ export default { controlSetRows: [['metric'], ['adhoc_filters']], }, { - label: t('Options'), + label: t('Display settings'), expanded: true, tabOverride: 'data', controlSetRows: [ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx index 83340ba9eb268..a77dc541733c1 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx @@ -277,59 +277,56 @@ class BigNumberVis extends React.PureComponent { } export default styled(BigNumberVis)` - font-family: ${({ theme }) => theme.typography.families.sansSerif}; - position: relative; - display: flex; - flex-direction: column; - justify-content: center; - - &.no-trendline .subheader-line { - padding-bottom: 0.3em; - } - - .text-container { + ${({ theme }) => ` + font-family: ${theme.typography.families.sansSerif}; + position: relative; display: flex; flex-direction: column; justify-content: center; - align-items: flex-start; - .alert { - font-size: ${({ theme }) => theme.typography.sizes.s}; - margin: -0.5em 0 0.4em; - line-height: 1; - padding: 2px 4px 3px; - border-radius: 3px; + + &.no-trendline .subheader-line { + padding-bottom: 0.3em; } - } - .kicker { - line-height: 1em; - padding-bottom: 2em; - } + .text-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + .alert { + font-size: ${theme.typography.sizes.s}; + margin: -0.5em 0 0.4em; + line-height: 1; + padding: ${theme.gridUnit}px; + border-radius: ${theme.gridUnit}px; + } + } - .header-line { - position: relative; - line-height: 1em; - span { - position: absolute; - bottom: 0; + .kicker { + line-height: 1em; + padding-bottom: 2em; } - } - .subheader-line { - line-height: 1em; - padding-bottom: 0; - } + .header-line { + position: relative; + line-height: 1em; + span { + position: absolute; + bottom: 0; + } + } - &.is-fallback-value { - .kicker, - .header-line, .subheader-line { - opacity: 0.5; + line-height: 1em; + padding-bottom: 0; } - } - .superset-data-ui-tooltip { - z-index: 1000; - background: #000; - } + &.is-fallback-value { + .kicker, + .header-line, + .subheader-line { + opacity: ${theme.opacity.mediumHeavy}; + } + } + `} `; 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 d55cf4664da1b..de75b50838ad6 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 @@ -18,63 +18,33 @@ */ import { buildQueryContext, - PostProcessingResample, + DTTM_ALIAS, QueryFormData, } from '@superset-ui/core'; import { flattenOperator, + pivotOperator, + resampleOperator, rollingWindowOperator, - sortOperator, } from '@superset-ui/chart-controls'; -const TIME_GRAIN_MAP: Record = { - PT1S: 'S', - PT1M: 'min', - PT5M: '5min', - PT10M: '10min', - PT15M: '15min', - PT30M: '30min', - PT1H: 'H', - P1D: 'D', - P1M: 'MS', - P3M: 'QS', - P1Y: 'AS', - // TODO: these need to be mapped carefully, as the first day of week - // can vary from engine to engine - // P1W: 'W', - // '1969-12-28T00:00:00Z/P1W': 'W', - // '1969-12-29T00:00:00Z/P1W': 'W', - // 'P1W/1970-01-03T00:00:00Z': 'W', - // 'P1W/1970-01-04T00:00:00Z': 'W', -}; - export default function buildQuery(formData: QueryFormData) { return buildQueryContext(formData, baseQueryObject => { - // todo: move into full advanced analysis section here - const rollingProc = rollingWindowOperator(formData, baseQueryObject); - const { time_grain_sqla } = formData; - let resampleProc: PostProcessingResample; - if (rollingProc && time_grain_sqla) { - const rule = TIME_GRAIN_MAP[time_grain_sqla]; - if (rule) { - resampleProc = { - operation: 'resample', - options: { - method: 'asfreq', - rule, - fill_value: null, - }, - }; - } - } + const { x_axis } = formData; + const is_timeseries = x_axis === DTTM_ALIAS || !x_axis; + return [ { ...baseQueryObject, is_timeseries: true, post_processing: [ - sortOperator(formData, baseQueryObject), - resampleProc, - rollingProc, + pivotOperator(formData, { + ...baseQueryObject, + index: x_axis, + is_timeseries, + }), + rollingWindowOperator(formData, baseQueryObject), + resampleOperator(formData, baseQueryObject), flattenOperator(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 c1378543f6b0c..3ba00f55ea212 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 @@ -164,7 +164,7 @@ const config: ControlPanelConfig = { expanded: false, controlSetRows: [ // eslint-disable-next-line react/jsx-key - [

{t('Rolling Window')}

], + [
{t('Rolling Window')}
], [ { name: 'rolling_type', @@ -217,6 +217,51 @@ const config: ControlPanelConfig = { }, }, ], + [
{t('Resample')}
], + [ + { + name: 'resample_rule', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Rule'), + default: null, + choices: [ + ['1T', '1 minutely frequency'], + ['1H', '1 hourly frequency'], + ['1D', '1 calendar day frequency'], + ['7D', '7 calendar day frequency'], + ['1MS', '1 month start frequency'], + ['1M', '1 month end frequency'], + ['1AS', '1 year start frequency'], + ['1A', '1 year end frequency'], + ], + description: t('Pandas resample rule'), + }, + }, + ], + [ + { + name: 'resample_method', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Fill method'), + default: null, + choices: [ + ['asfreq', 'Null imputation'], + ['zerofill', 'Zero imputation'], + ['linear', 'Linear interpolation'], + ['ffill', 'Forward values'], + ['bfill', 'Backward values'], + ['median', 'Median values'], + ['mean', 'Mean values'], + ['sum', 'Sum values'], + ], + description: t('Pandas resample method'), + }, + }, + ], ], }, ], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/controlPanel.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/controlPanel.ts index a9456dce3852b..f8e5cbb62950f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/controlPanel.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/controlPanel.ts @@ -125,7 +125,7 @@ const config: ControlPanelConfig = { ], controlOverrides: { groupby: { - label: t('Series'), + label: t('Dimensions'), description: t('Categories to group by on the x-axis.'), }, columns: { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/controlPanel.tsx index e1950bf9a5b37..fe2269cf89c05 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/controlPanel.tsx @@ -76,7 +76,7 @@ const config: ControlPanelConfig = { ['color_scheme'], ...funnelLegendSection, // eslint-disable-next-line react/jsx-key - [

{t('Labels')}

], + [
{t('Labels')}
], [ { name: 'label_type', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/controlPanel.tsx index 81af7d3963e49..ff03da4153b18 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/controlPanel.tsx @@ -39,7 +39,7 @@ const config: ControlPanelConfig = { name: 'groupby', config: { ...sharedControls.groupby, - label: t('Group by'), + label: t('Dimensions'), description: t('Columns to group by'), }, }, @@ -75,7 +75,7 @@ const config: ControlPanelConfig = { label: t('Chart Options'), expanded: true, controlSetRows: [ - [

{t('General')}

], + [
{t('General')}
], [ { name: 'min_val', @@ -197,7 +197,7 @@ const config: ControlPanelConfig = { }, }, ], - [

{t('Axis')}

], + [
{t('Axis')}
], [ { name: 'show_axis_tick', @@ -236,7 +236,7 @@ const config: ControlPanelConfig = { }, }, ], - [

{t('Progress')}

], + [
{t('Progress')}
], [ { name: 'show_progress', @@ -277,7 +277,7 @@ const config: ControlPanelConfig = { }, }, ], - [

{t('Intervals')}

], + [
{t('Intervals')}
], [ { name: 'intervals', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/controlPanel.tsx index cdefae16cab54..cb2f586110177 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/controlPanel.tsx @@ -98,7 +98,7 @@ const controlPanel: ControlPanelConfig = { controlSetRows: [ ['color_scheme'], ...legendSection, - [

{t('Layout')}

], + [
{t('Layout')}
], [ { name: 'layout', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx index 8cd681c5e33e1..97955eec3500c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx @@ -126,7 +126,7 @@ function createCustomizeSection( controlSuffix: string, ): ControlSetRow[] { return [ - [

{label}

], + [
{label}
], [ { name: `seriesType${controlSuffix}`, @@ -296,7 +296,7 @@ const config: ControlPanelConfig = { }, ], ...legendSection, - [

{t('X Axis')}

], + [
{t('X Axis')}
], ['x_axis_time_format'], [ { @@ -320,7 +320,7 @@ const config: ControlPanelConfig = { ], ...richTooltipSection, // eslint-disable-next-line react/jsx-key - [

{t('Y Axis')}

], + [
{t('Y Axis')}
], [ { name: 'minorSplitLine', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx index aab4af54585b4..9056446f9f412 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx @@ -90,7 +90,7 @@ const config: ControlPanelConfig = { ], ...legendSection, // eslint-disable-next-line react/jsx-key - [

{t('Labels')}

], + [
{t('Labels')}
], [ { name: 'label_type', @@ -183,8 +183,20 @@ const config: ControlPanelConfig = { }, }, ], + [ + { + name: 'show_total', + config: { + type: 'CheckboxControl', + label: t('Show Total'), + default: false, + renderTrigger: true, + description: t('Whether to display the aggregate count'), + }, + }, + ], // eslint-disable-next-line react/jsx-key - [

{t('Pie shape')}

], + [
{t('Pie shape')}
], [ { name: 'outerRadius', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts index 237f4ae001f70..cf40ce7e1be94 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts @@ -25,6 +25,7 @@ import { getTimeFormatter, NumberFormats, NumberFormatter, + t, } from '@superset-ui/core'; import { CallbackDataParams } from 'echarts/types/src/util/types'; import { EChartsCoreOption, PieSeriesOption } from 'echarts'; @@ -45,6 +46,7 @@ import { } from '../utils/series'; import { defaultGrid, defaultTooltip } from '../defaults'; import { OpacityEnum } from '../constants'; +import { convertInteger } from '../utils/convertInteger'; const percentFormatter = getNumberFormatter(NumberFormats.PERCENT_2_POINT); @@ -82,6 +84,54 @@ export function formatPieLabel({ } } +function getTotalValuePadding({ + chartPadding, + donut, + width, + height, +}: { + chartPadding: { + bottom: number; + left: number; + right: number; + top: number; + }; + donut: boolean; + width: number; + height: number; +}) { + const padding: { + left?: string; + top?: string; + } = { + top: donut ? 'middle' : '0', + left: 'center', + }; + const LEGEND_HEIGHT = 15; + const LEGEND_WIDTH = 215; + if (chartPadding.top) { + padding.top = donut + ? `${50 + ((chartPadding.top - LEGEND_HEIGHT) / height / 2) * 100}%` + : `${((chartPadding.top + LEGEND_HEIGHT) / height) * 100}%`; + } + if (chartPadding.bottom) { + padding.top = donut + ? `${50 - ((chartPadding.bottom + LEGEND_HEIGHT) / height / 2) * 100}%` + : '0'; + } + if (chartPadding.left) { + padding.left = `${ + 50 + ((chartPadding.left - LEGEND_WIDTH) / width / 2) * 100 + }%`; + } + if (chartPadding.right) { + padding.left = `${ + 50 - ((chartPadding.right + LEGEND_WIDTH) / width / 2) * 100 + }%`; + } + return padding; +} + export default function transformProps( chartProps: EchartsPieChartProps, ): PieChartTransformedProps { @@ -110,6 +160,7 @@ export default function transformProps( showLabelsThreshold, emitFilter, sliceId, + showTotal, }: EchartsPieFormData = { ...DEFAULT_LEGEND_FORM_DATA, ...DEFAULT_PIE_FORM_DATA, @@ -147,6 +198,7 @@ export default function transformProps( const colorFn = CategoricalColorNamespace.getScale(colorScheme as string); const numberFormatter = getNumberFormatter(numberFormat); + let totalValue = 0; const transformedData: PieSeriesOption[] = data.map(datum => { const name = extractGroupbyLabel({ @@ -158,9 +210,14 @@ export default function transformProps( const isFiltered = filterState.selectedValues && !filterState.selectedValues.includes(name); + const value = datum[metricLabel]; + + if (typeof value === 'number' || typeof value === 'string') { + totalValue += convertInteger(value); + } return { - value: datum[metricLabel], + value, name, itemStyle: { color: colorFn(name, sliceId), @@ -197,10 +254,16 @@ export default function transformProps( color: '#000000', }; + const chartPadding = getChartPadding( + showLegend, + legendOrientation, + legendMargin, + ); + const series: PieSeriesOption[] = [ { type: 'pie', - ...getChartPadding(showLegend, legendOrientation, legendMargin), + ...chartPadding, animation: false, radius: [`${donut ? innerRadius : 0}%`, `${outerRadius}%`], center: ['50%', '50%'], @@ -248,6 +311,18 @@ export default function transformProps( ...getLegendProps(legendType, legendOrientation, showLegend), data: keys, }, + graphic: showTotal + ? { + type: 'text', + ...getTotalValuePadding({ chartPadding, donut, width, height }), + style: { + text: t('Total: %s', numberFormatter(totalValue)), + fontSize: 16, + fontWeight: 'bold', + }, + z: 10, + } + : null, series, }; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/controlPanel.tsx index 0f8e390802a56..d24497280ae6b 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/controlPanel.tsx @@ -85,7 +85,7 @@ const config: ControlPanelConfig = { controlSetRows: [ ['color_scheme'], ...legendSection, - [

{t('Labels')}

], + [
{t('Labels')}
], [ { name: 'show_labels', @@ -158,7 +158,7 @@ const config: ControlPanelConfig = { }, }, ], - [

{t('Radar')}

], + [
{t('Radar')}
], [ { name: 'column_config', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx index 87503166b7977..b973cb6782c03 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx @@ -178,7 +178,7 @@ const config: ControlPanelConfig = { }, ], ...legendSection, - [

{t('X Axis')}

], + [
{t('X Axis')}
], [ { name: 'x_axis_time_format', @@ -213,7 +213,7 @@ const config: ControlPanelConfig = { ], ...richTooltipSection, // eslint-disable-next-line react/jsx-key - [

{t('Y Axis')}

], + [
{t('Y Axis')}
], ['y_axis_format'], [ { 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 bd40eeebe0e75..a3b74aa12f4fe 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 @@ -139,7 +139,7 @@ const config: ControlPanelConfig = { }, ], ...legendSection, - [

{t('X Axis')}

], + [
{t('X Axis')}
], [ { name: 'x_axis_time_format', @@ -175,7 +175,7 @@ const config: ControlPanelConfig = { // eslint-disable-next-line react/jsx-key ...richTooltipSection, // eslint-disable-next-line react/jsx-key - [

{t('Y Axis')}

], + [
{t('Y Axis')}
], ['y_axis_format'], [ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx index 4cdf16c8395a2..abc5e9a29e724 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx @@ -119,7 +119,7 @@ const config: ControlPanelConfig = { }, ], ...legendSection, - [

{t('X Axis')}

], + [
{t('X Axis')}
], [ { @@ -156,7 +156,7 @@ const config: ControlPanelConfig = { // eslint-disable-next-line react/jsx-key ...richTooltipSection, // eslint-disable-next-line react/jsx-key - [

{t('Y Axis')}

], + [
{t('Y Axis')}
], ['y_axis_format'], [ { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/controlPanel.tsx index d2f3acce9e08f..f234df0c82b4a 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/controlPanel.tsx @@ -136,7 +136,7 @@ const config: ControlPanelConfig = { }, ], ...legendSection, - [

{t('X Axis')}

], + [
{t('X Axis')}
], [ { name: 'x_axis_time_format', @@ -172,7 +172,7 @@ const config: ControlPanelConfig = { // eslint-disable-next-line react/jsx-key ...richTooltipSection, // eslint-disable-next-line react/jsx-key - [

{t('Y Axis')}

], + [
{t('Y Axis')}
], ['y_axis_format'], [ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx index 1416a7db4686c..b8d3a31b2c295 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx @@ -194,7 +194,7 @@ const config: ControlPanelConfig = { }, ], ...legendSection, - [

{t('X Axis')}

], + [
{t('X Axis')}
], [ { name: 'x_axis_time_format', @@ -229,7 +229,7 @@ const config: ControlPanelConfig = { ], ...richTooltipSection, // eslint-disable-next-line react/jsx-key - [

{t('Y Axis')}

], + [
{t('Y Axis')}
], ['y_axis_format'], [ { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts index c4cdaa9360a64..3478c73470fc7 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts @@ -27,9 +27,10 @@ import { import { rollingWindowOperator, timeCompareOperator, - isValidTimeCompare, + isTimeComparison, pivotOperator, resampleOperator, + renameOperator, contributionOperator, prophetOperator, timeComparePivotOperator, @@ -60,7 +61,7 @@ export default function buildQuery(formData: QueryFormData) { 2015-03-01 318.0 0.0 */ - const pivotOperatorInRuntime: PostProcessingPivot = isValidTimeCompare( + const pivotOperatorInRuntime: PostProcessingPivot = isTimeComparison( formData, baseQueryObject, ) @@ -79,7 +80,7 @@ export default function buildQuery(formData: QueryFormData) { is_timeseries, // todo: move `normalizeOrderBy to extractQueryFields` orderby: normalizeOrderBy(baseQueryObject).orderby, - time_offsets: isValidTimeCompare(formData, baseQueryObject) + time_offsets: isTimeComparison(formData, baseQueryObject) ? formData.time_compare : [], /* Note that: @@ -91,7 +92,12 @@ export default function buildQuery(formData: QueryFormData) { rollingWindowOperator(formData, baseQueryObject), timeCompareOperator(formData, baseQueryObject), resampleOperator(formData, baseQueryObject), + renameOperator(formData, { + ...baseQueryObject, + ...{ is_timeseries }, + }), flattenOperator(formData, baseQueryObject), + // todo: move contribution and prophet before flatten contributionOperator(formData, baseQueryObject), prophetOperator(formData, baseQueryObject), ], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/controlPanel.tsx index 1f1e22b49b3a5..8f22acadeefc3 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/controlPanel.tsx @@ -197,7 +197,7 @@ const config: ControlPanelConfig = { }, ], ...legendSection, - [

{t('X Axis')}

], + [
{t('X Axis')}
], [ { name: 'x_axis_time_format', @@ -232,7 +232,7 @@ const config: ControlPanelConfig = { ], ...richTooltipSection, // eslint-disable-next-line react/jsx-key - [

{t('Y Axis')}

], + [
{t('Y Axis')}
], ['y_axis_format'], [ { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts index 1a2200db22097..b8585c6e68ed8 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -28,6 +28,7 @@ import { isFormulaAnnotationLayer, isIntervalAnnotationLayer, isTimeseriesAnnotationLayer, + TimeGranularity, TimeseriesChartDataResponseResult, } from '@superset-ui/core'; import { EChartsCoreOption, SeriesOption } from 'echarts'; @@ -69,6 +70,14 @@ import { } from './transformers'; import { TIMESERIES_CONSTANTS } from '../constants'; +const TimeGrainToTimestamp = { + [TimeGranularity.HOUR]: 3600 * 1000, + [TimeGranularity.DAY]: 3600 * 1000 * 24, + [TimeGranularity.MONTH]: 3600 * 1000 * 24 * 31, + [TimeGranularity.QUARTER]: 3600 * 1000 * 24 * 31 * 3, + [TimeGranularity.YEAR]: 3600 * 1000 * 24 * 31 * 12, +}; + export default function transformProps( chartProps: EchartsTimeseriesChartProps, ): TimeseriesChartTransformedProps { @@ -126,6 +135,7 @@ export default function transformProps( yAxisTitleMargin, yAxisTitlePosition, sliceId, + timeGrainSqla, }: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData }; const colorScale = CategoricalColorNamespace.getScale(colorScheme as string); @@ -324,6 +334,10 @@ export default function transformProps( formatter: xAxisFormatter, rotate: xAxisLabelRotation, }, + minInterval: + xAxisType === 'time' && timeGrainSqla + ? TimeGrainToTimestamp[timeGrainSqla] + : 0, }, yAxis: { ...defaultYAxis, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts index 7ce72695be2b5..7e8dbf855cb8a 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts @@ -357,7 +357,7 @@ export function transformEventAnnotation( const eventData: MarkLine1DDataItemOption[] = [ { name: label, - xAxis: time as unknown as number, + xAxis: time, }, ]; 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 aa4a38fca871b..cd48e0f636e0b 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Tree/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Tree/controlPanel.tsx @@ -107,7 +107,7 @@ const controlPanel: ControlPanelConfig = { label: t('Chart options'), expanded: true, controlSetRows: [ - [

{t('Layout')}

], + [
{t('Layout')}
], [ { name: 'layout', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/controlPanel.tsx index 9f6d4e297e031..63ca40225ffe6 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/controlPanel.tsx @@ -62,7 +62,7 @@ const config: ControlPanelConfig = { expanded: true, controlSetRows: [ ['color_scheme'], - [

{t('Labels')}

], + [
{t('Labels')}
], [ { name: 'show_labels', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx index 053d0db8359fd..df050e6dbb418 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx @@ -94,7 +94,7 @@ const legendOrientationControl: ControlSetItem = { }; export const legendSection: ControlSetRow[] = [ - [

{t('Legend')}

], + [
{t('Legend')}
], [showLegendControl], [legendTypeControl], [legendOrientationControl], @@ -219,7 +219,7 @@ const tooltipSortByMetricControl: ControlSetItem = { }; export const richTooltipSection: ControlSetRow[] = [ - [

{t('Tooltip')}

], + [
{t('Tooltip')}
], [richTooltipControl], [tooltipSortByMetricControl], [tooltipTimeFormatControl], diff --git a/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx b/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx index 240073210ea3b..9a98fee431817 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx @@ -183,7 +183,9 @@ function StickyWrap({ .clientHeight; const ths = bodyThead.childNodes[0] .childNodes as NodeListOf; - const widths = Array.from(ths).map(th => th.clientWidth); + const widths = Array.from(ths).map( + th => th.getBoundingClientRect()?.width || th.clientWidth, + ); const [hasVerticalScroll, hasHorizontalScroll] = needScrollBar({ width: maxWidth, height: maxHeight - theadHeight - tfootHeight, diff --git a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx index 5b9abfb163d9b..c121547518e46 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx @@ -117,6 +117,7 @@ const all_columns: typeof sharedControls.groupby = { : [], }), visibility: isRawMode, + resetOnHide: false, }; const dnd_all_columns: typeof sharedControls.groupby = { @@ -140,6 +141,7 @@ const dnd_all_columns: typeof sharedControls.groupby = { return newState; }, visibility: isRawMode, + resetOnHide: false, }; const percent_metrics: typeof sharedControls.metrics = { @@ -150,6 +152,7 @@ const percent_metrics: typeof sharedControls.metrics = { ), multi: true, visibility: isAggMode, + resetOnHide: false, mapStateToProps: ({ datasource, controls }, controlState) => ({ columns: datasource?.columns || [], savedMetrics: datasource?.metrics || [], @@ -190,6 +193,7 @@ const config: ControlPanelConfig = { name: 'groupby', override: { visibility: isAggMode, + resetOnHide: false, mapStateToProps: ( state: ControlPanelState, controlState: ControlState, @@ -220,6 +224,7 @@ const config: ControlPanelConfig = { override: { validators: [], visibility: isAggMode, + resetOnHide: false, mapStateToProps: ( { controls, datasource, form_data }: ControlPanelState, controlState: ControlState, @@ -263,6 +268,7 @@ const config: ControlPanelConfig = { name: 'timeseries_limit_metric', override: { visibility: isAggMode, + resetOnHide: false, }, }, { @@ -277,6 +283,7 @@ const config: ControlPanelConfig = { choices: datasource?.order_by_choices || [], }), visibility: isRawMode, + resetOnHide: false, }, }, ], @@ -329,6 +336,7 @@ const config: ControlPanelConfig = { ), default: false, visibility: isAggMode, + resetOnHide: false, }, }, { @@ -339,6 +347,7 @@ const config: ControlPanelConfig = { default: true, description: t('Whether to sort descending or ascending'), visibility: isAggMode, + resetOnHide: false, }, }, ], @@ -353,6 +362,7 @@ const config: ControlPanelConfig = { 'Show total aggregations of selected metrics. Note that row limit does not apply to the result.', ), visibility: isAggMode, + resetOnHide: false, }, }, ], diff --git a/superset-frontend/src/SqlLab/App.jsx b/superset-frontend/src/SqlLab/App.jsx index e2118e2efd084..02a4df2a6f8d9 100644 --- a/superset-frontend/src/SqlLab/App.jsx +++ b/superset-frontend/src/SqlLab/App.jsx @@ -41,7 +41,6 @@ import setupApp from '../setup/setupApp'; import './main.less'; import '../assets/stylesheets/reactable-pagination.less'; -import '../components/FilterableTable/FilterableTableStyles.less'; import { theme } from '../preamble'; setupApp(); diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.js b/superset-frontend/src/SqlLab/actions/sqlLab.js index 02e07d5831e9b..3d1298e6c3b73 100644 --- a/superset-frontend/src/SqlLab/actions/sqlLab.js +++ b/superset-frontend/src/SqlLab/actions/sqlLab.js @@ -791,7 +791,7 @@ export function queryEditorSetSchema(queryEditor, schema) { dispatch({ type: QUERY_EDITOR_SET_SCHEMA, queryEditor: queryEditor || {}, - schema: schema || {}, + schema, }), ) .catch(() => @@ -1393,10 +1393,21 @@ export function queryEditorSetFunctionNames(queryEditor, dbId) { functionNames: json.function_names, }), ) - .catch(() => - dispatch( - addDangerToast(t('An error occurred while fetching function names.')), - ), - ); + .catch(err => { + if (err.status === 404) { + // for databases that have been deleted, just reset the function names + dispatch({ + type: QUERY_EDITOR_SET_FUNCTION_NAMES, + queryEditor, + functionNames: [], + }); + } else { + dispatch( + addDangerToast( + t('An error occurred while fetching function names.'), + ), + ); + } + }); }; } diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx index 35722ba866066..53ec3f808a62f 100644 --- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx +++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx @@ -66,7 +66,6 @@ interface Props { interface State { sql: string; - selectedText: string; words: AceCompleterKeyword[]; } @@ -80,13 +79,20 @@ class AceEditorWrapper extends React.PureComponent { extendedTables: [], }; + private currentSelectionCache; + constructor(props: Props) { super(props); this.state = { sql: props.sql, - selectedText: '', words: [], }; + + // The editor changeSelection is called multiple times in a row, + // faster than React reconciliation process, so the selected text + // needs to be stored out of the state to ensure changes to it + // get saved immediately + this.currentSelectionCache = ''; this.onChange = this.onChange.bind(this); } @@ -146,17 +152,19 @@ class AceEditorWrapper extends React.PureComponent { editor.$blockScrolling = Infinity; // eslint-disable-line no-param-reassign editor.selection.on('changeSelection', () => { const selectedText = editor.getSelectedText(); + // Backspace trigger 1 character selection, ignoring if ( - selectedText !== this.state.selectedText && + selectedText !== this.currentSelectionCache && selectedText.length !== 1 ) { - this.setState({ selectedText }); this.props.actions.queryEditorSetSelectedText( this.props.queryEditor, selectedText, ); } + + this.currentSelectionCache = selectedText; }); } @@ -219,11 +227,15 @@ class AceEditorWrapper extends React.PureComponent { this.props.queryEditor.schema, ); } + + let { caption } = data; + if (data.meta === 'table' && caption.includes(' ')) { + caption = `"${caption}"`; + } + // executing https://github.com/thlorenz/brace/blob/3a00c5d59777f9d826841178e1eb36694177f5e6/ext/language_tools.js#L1448 editor.completer.insertMatch( - `${data.caption}${ - ['function', 'schema'].includes(data.meta) ? '' : ' ' - }`, + `${caption}${['function', 'schema'].includes(data.meta) ? '' : ' '}`, ); }, }; diff --git a/superset-frontend/src/SqlLab/components/QueryHistory/QueryHistory.test.tsx b/superset-frontend/src/SqlLab/components/QueryHistory/QueryHistory.test.tsx index 782b147839186..e63de3fdca869 100644 --- a/superset-frontend/src/SqlLab/components/QueryHistory/QueryHistory.test.tsx +++ b/superset-frontend/src/SqlLab/components/QueryHistory/QueryHistory.test.tsx @@ -31,6 +31,7 @@ const mockedProps = { removeQuery: NOOP, }, displayLimit: 1000, + latestQueryId: 'yhMUZCGb', }; const setup = (overrides = {}) => ( diff --git a/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx b/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx index 7cf9d6ba657dd..6820e19d49deb 100644 --- a/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx +++ b/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx @@ -32,6 +32,7 @@ interface QueryHistoryProps { removeQuery: Function; }; displayLimit: number; + latestQueryId: string | undefined; } const StyledEmptyStateWrapper = styled.div` @@ -45,7 +46,12 @@ const StyledEmptyStateWrapper = styled.div` } `; -const QueryHistory = ({ queries, actions, displayLimit }: QueryHistoryProps) => +const QueryHistory = ({ + queries, + actions, + displayLimit, + latestQueryId, +}: QueryHistoryProps) => queries.length > 0 ? ( 'progress', 'rows', 'sql', - 'output', + 'results', 'actions', ]} queries={queries} actions={actions} displayLimit={displayLimit} + latestQueryId={latestQueryId} /> ) : ( diff --git a/superset-frontend/src/SqlLab/components/QuerySearch/index.tsx b/superset-frontend/src/SqlLab/components/QuerySearch/index.tsx index ae2562207e2b3..762f35e89880e 100644 --- a/superset-frontend/src/SqlLab/components/QuerySearch/index.tsx +++ b/superset-frontend/src/SqlLab/components/QuerySearch/index.tsx @@ -226,7 +226,7 @@ function QuerySearch({ actions, displayLimit }: QuerySearchProps) { value: xt, label: xt, }))} - value={from as unknown as undefined} + value={{ value: from, label: from }} autosize={false} onChange={(selected: any) => setFrom(selected?.value)} /> @@ -235,7 +235,7 @@ function QuerySearch({ actions, displayLimit }: QuerySearchProps) { name="select-to" placeholder={t('[To]-')} options={TIME_OPTIONS.map(xt => ({ value: xt, label: xt }))} - value={to as unknown as undefined} + value={{ value: to, label: to }} autosize={false} onChange={(selected: any) => setTo(selected?.value)} /> @@ -247,7 +247,7 @@ function QuerySearch({ actions, displayLimit }: QuerySearchProps) { value: s, label: s, }))} - value={status as unknown as undefined} + value={{ value: status, label: status }} isLoading={false} autosize={false} onChange={(selected: any) => setStatus(selected?.value)} diff --git a/superset-frontend/src/SqlLab/components/QueryTable/QueryTable.test.jsx b/superset-frontend/src/SqlLab/components/QueryTable/QueryTable.test.jsx index 5be5a384863f9..f77e631ae2f5c 100644 --- a/superset-frontend/src/SqlLab/components/QueryTable/QueryTable.test.jsx +++ b/superset-frontend/src/SqlLab/components/QueryTable/QueryTable.test.jsx @@ -32,6 +32,7 @@ describe('QueryTable', () => { queries, displayLimit: 100, actions, + latestQueryId: 'ryhMUZCGb', }; it('is valid', () => { expect(React.isValidElement()).toBe(true); diff --git a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx index 142d8a13099da..a50779d6eb9c1 100644 --- a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx +++ b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx @@ -35,10 +35,12 @@ import ResultSet from '../ResultSet'; import HighlightedSql from '../HighlightedSql'; import { StaticPosition, verticalAlign, StyledTooltip } from './styles'; -interface QueryTableQuery extends Omit { +interface QueryTableQuery + extends Omit { state?: Record; sql?: Record; progress?: Record; + results?: Record; } interface QueryTableProps { @@ -54,6 +56,7 @@ interface QueryTableProps { onUserClicked?: Function; onDbClicked?: Function; displayLimit: number; + latestQueryId?: string | undefined; } const openQuery = (id: number) => { @@ -68,6 +71,7 @@ const QueryTable = ({ onUserClicked = () => undefined, onDbClicked = () => undefined, displayLimit, + latestQueryId, }: QueryTableProps) => { const theme = useTheme(); @@ -225,12 +229,12 @@ const QueryTable = ({ ); if (q.resultsKey) { - q.output = ( + q.results = ( - {t('View results')} + {t('View')} } modalTitle={t('Data preview')} @@ -251,12 +255,9 @@ const QueryTable = ({ /> ); } else { - // if query was run using ctas and force_ctas_schema was set - // tempTable will have the schema - const schemaUsed = - q.ctas && q.tempTable && q.tempTable.includes('.') ? '' : q.schema; - q.output = [schemaUsed, q.tempTable].filter(v => v).join('.'); + q.results = <>; } + q.progress = state === 'success' ? ( - + openQueryInNewTab(query)} tooltip={t('Run query in a new tab')} placement="top" > - - - removeQuery(query)} - > - + + {q.id !== latestQueryId && ( + removeQuery(query)} + > + + + )}
); return q; diff --git a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx index dbde25ef2b4f2..240f110f5d4f9 100644 --- a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx +++ b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx @@ -247,9 +247,16 @@ export default class ResultSet extends React.PureComponent< this.clearQueryResults(nextProps.query), ); } + + // Only fetch results if the result key change + // If we didn't have a result key before, then the results are loaded elsewhere + // so we can skip it, unless the query id changed, in that case we should + // refetch regardless. if ( - nextProps.query.resultsKey && - nextProps.query.resultsKey !== this.props.query.resultsKey + (this.props.query.resultsKey && + nextProps.query.resultsKey && + nextProps.query.resultsKey !== this.props.query.resultsKey) || + (nextProps.query.id !== this.props.query.id && nextProps.query.resultsKey) ) { this.fetchResults(nextProps.query); } @@ -280,7 +287,8 @@ export default class ResultSet extends React.PureComponent< sql, results.selected_columns.map(d => ({ column_name: d.name, - is_dttm: d.is_date, + type: d.type, + is_dttm: d.is_dttm, })), datasetToOverwrite.owners.map((o: DatasetOwner) => o.id), true, diff --git a/superset-frontend/src/SqlLab/components/ScheduleQueryButton/ScheduleQueryButton.less b/superset-frontend/src/SqlLab/components/ScheduleQueryButton/ScheduleQueryButton.less deleted file mode 100644 index 4ae5847227caa..0000000000000 --- a/superset-frontend/src/SqlLab/components/ScheduleQueryButton/ScheduleQueryButton.less +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -// ------------------------------------------------------------- -// Glyphicons are not supported and used by react-json-schema -// ------------------------------------------------------------- -.json-schema { - i.glyphicon { - display: none; - } - .btn-add::after { - content: '+'; - } - .array-item-move-up::after { - content: '↑'; - } - .array-item-move-down::after { - content: '↓'; - } - .array-item-remove::after { - content: '-'; - } -} -// ------------------------------------------------------------- diff --git a/superset-frontend/src/SqlLab/components/ScheduleQueryButton/index.tsx b/superset-frontend/src/SqlLab/components/ScheduleQueryButton/index.tsx index 900e34d05a594..e1c7b54d025ea 100644 --- a/superset-frontend/src/SqlLab/components/ScheduleQueryButton/index.tsx +++ b/superset-frontend/src/SqlLab/components/ScheduleQueryButton/index.tsx @@ -24,7 +24,6 @@ import { t, styled } from '@superset-ui/core'; import * as chrono from 'chrono-node'; import ModalTrigger from 'src/components/ModalTrigger'; import { Form, FormItem } from 'src/components/Form'; -import './ScheduleQueryButton.less'; import Button from 'src/components/Button'; const appContainer = document.getElementById('app'); @@ -111,6 +110,24 @@ export const StyledButtonComponent = styled(Button)` } `; +const StyledJsonSchema = styled.div` + i.glyphicon { + display: none; + } + .btn-add::after { + content: '+'; + } + .array-item-move-up::after { + content: '↑'; + } + .array-item-move-down::after { + content: '↓'; + } + .array-item-remove::after { + content: '-'; + } +`; + const ScheduleQueryButton: FunctionComponent = ({ defaultLabel = t('Undefined'), sql, @@ -175,7 +192,7 @@ const ScheduleQueryButton: FunctionComponent = ({ -
+ = ({ Submit -
+
{scheduleQueryWarning && ( diff --git a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx index afdb13e6f7fb3..767b608f3b7d2 100644 --- a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx +++ b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx @@ -224,6 +224,7 @@ export default function SouthPane({ queries={editorQueries} actions={actions} displayLimit={displayLimit} + latestQueryId={latestQueryId} /> {renderDataPreviewTabs()} diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx index f3549b547f8b1..d946c675cc8c4 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx @@ -38,6 +38,7 @@ import { queryEditorSetSelectedText, queryEditorSetSchemaOptions, } from 'src/SqlLab/actions/sqlLab'; +import { EmptyStateBig } from 'src/components/EmptyState'; import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; import { initialState, queries, table } from 'src/SqlLab/fixtures'; @@ -57,7 +58,19 @@ describe('SqlEditor', () => { queryEditorSetSchemaOptions, addDangerToast: jest.fn(), }, - database: {}, + database: { + allow_ctas: false, + allow_cvas: false, + allow_dml: false, + allow_file_upload: false, + allow_multi_schema_metadata_fetch: false, + allow_run_async: false, + backend: 'postgresql', + database_name: 'examples', + expose_in_sqllab: true, + force_ctas_schema: null, + id: 1, + }, queryEditorId: initialState.sqlLab.queryEditors[0].id, latestQuery: queries[0], tables: [table], @@ -80,6 +93,12 @@ describe('SqlEditor', () => { }, ); + it('does not render SqlEditor if no db selected', () => { + const database = {}; + const updatedProps = { ...mockedProps, database }; + const wrapper = buildWrapper(updatedProps); + expect(wrapper.find(EmptyStateBig)).toExist(); + }); it('render a SqlEditorLeftBar', async () => { const wrapper = buildWrapper(); await waitForComponentToPaint(wrapper); diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx index 7899cbf71908a..df1a9a77c57a6 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx @@ -66,6 +66,8 @@ import { setItem, } from 'src/utils/localStorageHelpers'; import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags'; +import { EmptyStateBig } from 'src/components/EmptyState'; +import { isEmpty } from 'lodash'; import TemplateParamsEditor from '../TemplateParamsEditor'; import ConnectedSouthPane from '../SouthPane/state'; import SaveQuery from '../SaveQuery'; @@ -75,6 +77,7 @@ import ShareSqlLabQuery from '../ShareSqlLabQuery'; import SqlEditorLeftBar from '../SqlEditorLeftBar'; import AceEditorWrapper from '../AceEditorWrapper'; import RunQueryActionButton from '../RunQueryActionButton'; +import { newQueryTabName } from '../../utils/newQueryTabName'; const LIMIT_DROPDOWN = [10, 100, 1000, 10000, 100000]; const SQL_EDITOR_PADDING = 10; @@ -179,6 +182,7 @@ class SqlEditor extends React.PureComponent { ), showCreateAsModal: false, createAs: '', + showEmptyState: false, }; this.sqlEditorRef = React.createRef(); this.northPaneRef = React.createRef(); @@ -188,6 +192,7 @@ class SqlEditor extends React.PureComponent { this.onResizeEnd = this.onResizeEnd.bind(this); this.canValidateQuery = this.canValidateQuery.bind(this); this.runQuery = this.runQuery.bind(this); + this.setEmptyState = this.setEmptyState.bind(this); this.stopQuery = this.stopQuery.bind(this); this.saveQuery = this.saveQuery.bind(this); this.onSqlChanged = this.onSqlChanged.bind(this); @@ -227,7 +232,11 @@ class SqlEditor extends React.PureComponent { // We need to measure the height of the sql editor post render to figure the height of // the south pane so it gets rendered properly // eslint-disable-next-line react/no-did-mount-set-state + const db = this.props.database; this.setState({ height: this.getSqlEditorHeight() }); + if (!db || isEmpty(db)) { + this.setEmptyState(true); + } window.addEventListener('resize', this.handleWindowResize); window.addEventListener('beforeunload', this.onBeforeUnload); @@ -239,6 +248,12 @@ class SqlEditor extends React.PureComponent { }); } + componentDidUpdate() { + if (this.props.queryEditor.sql !== this.state.sql) { + this.onSqlChanged(this.props.queryEditor.sql); + } + } + componentWillUnmount() { window.removeEventListener('resize', this.handleWindowResize); window.removeEventListener('beforeunload', this.onBeforeUnload); @@ -333,10 +348,10 @@ class SqlEditor extends React.PureComponent { key: userOS === 'Windows' ? 'ctrl+q' : 'ctrl+t', descr: t('New tab'), func: () => { + const title = newQueryTabName(this.props.queryEditors || []); this.props.addQueryEditor({ ...this.props.queryEditor, - title: t('Untitled query'), - sql: '', + title, }); }, }, @@ -362,6 +377,10 @@ class SqlEditor extends React.PureComponent { return base; } + setEmptyState(bool) { + this.setState({ showEmptyState: bool }); + } + setQueryEditorSql(sql) { this.props.queryEditorSetSql(this.props.queryEditor, sql); } @@ -753,10 +772,21 @@ class SqlEditor extends React.PureComponent { queryEditor={this.props.queryEditor} tables={this.props.tables} actions={this.props.actions} + setEmptyState={this.setEmptyState} /> - {this.queryPane()} + {this.state.showEmptyState ? ( + + ) : ( + this.queryPane() + )} editor.id === props.queryEditorId, ); - return { sqlLab, ...props, queryEditor }; + return { sqlLab, ...props, queryEditor, queryEditors: sqlLab.queryEditors }; } function mapDispatchToProps(dispatch) { diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx index f9e8c2da9f98f..f74249465456a 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx @@ -16,15 +16,24 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useEffect, useRef, useCallback } from 'react'; +import React, { + useEffect, + useRef, + useCallback, + useMemo, + useState, + Dispatch, + SetStateAction, +} from 'react'; 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 TableSelector from 'src/components/TableSelector'; +import { TableSelectorMultiple } from 'src/components/TableSelector'; import { IconTooltip } from 'src/components/IconTooltip'; import { QueryEditor } from 'src/SqlLab/types'; import { DatabaseObject } from 'src/components/DatabaseSelector'; +import { EmptyStateSmall } from 'src/components/EmptyState'; import TableElement, { Table, TableElementProps } from '../TableElement'; interface ExtendedTable extends Table { @@ -54,6 +63,8 @@ interface SqlEditorLeftBarProps { tables?: ExtendedTable[]; actions: actionsTypes & TableElementProps['actions']; database: DatabaseObject; + setEmptyState: Dispatch>; + showDisabled: boolean; } const StyledScrollbarContainer = styled.div` @@ -88,23 +99,53 @@ export default function SqlEditorLeftBar({ queryEditor, tables = [], height = 500, + setEmptyState, }: SqlEditorLeftBarProps) { // Ref needed to avoid infinite rerenders on handlers // that require and modify the queryEditor const queryEditorRef = useRef(queryEditor); + const [emptyResultsWithSearch, setEmptyResultsWithSearch] = useState(false); + useEffect(() => { queryEditorRef.current = queryEditor; }, [queryEditor]); + const onEmptyResults = (searchText?: string) => { + setEmptyResultsWithSearch(!!searchText); + }; + const onDbChange = ({ id: dbId }: { id: number }) => { + setEmptyState(false); actions.queryEditorSetDb(queryEditor, dbId); actions.queryEditorSetFunctionNames(queryEditor, dbId); }; - const onTableChange = (tableName: string, schemaName: string) => { - if (tableName && schemaName) { - actions.addTable(queryEditor, database, tableName, schemaName); + const selectedTableNames = useMemo( + () => tables?.map(table => table.name) || [], + [tables], + ); + + const onTablesChange = (tableNames: string[], schemaName: string) => { + if (!schemaName) { + return; } + + const currentTables = [...tables]; + const tablesToAdd = tableNames.filter(name => { + const index = currentTables.findIndex(table => table.name === name); + if (index >= 0) { + currentTables.splice(index, 1); + return false; + } + + return true; + }); + + tablesToAdd.forEach(tableName => + actions.addTable(queryEditor, database, tableName, schemaName), + ); + + currentTables.forEach(table => actions.removeTable(table)); }; const onToggleTable = (updatedTables: string[]) => { @@ -142,6 +183,22 @@ export default function SqlEditorLeftBar({ const shouldShowReset = window.location.search === '?reset=1'; const tableMetaDataHeight = height - 130; // 130 is the height of the selects above + const emptyStateComponent = ( + + {t('Manage your databases')}{' '} + {t('here')} +

+ } + /> + ); const handleSchemaChange = useCallback( (schema: string) => { if (queryEditorRef.current) { @@ -162,16 +219,19 @@ export default function SqlEditorLeftBar({ return (
-
diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx index 8c20a493b0876..494ef9cba0ef7 100644 --- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx +++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx @@ -31,6 +31,7 @@ import { Tooltip } from 'src/components/Tooltip'; import { detectOS } from 'src/utils/common'; import * as Actions from 'src/SqlLab/actions/sqlLab'; import { EmptyStateBig } from 'src/components/EmptyState'; +import { newQueryTabName } from '../../utils/newQueryTabName'; import SqlEditor from '../SqlEditor'; import TabStatusIcon from '../TabStatusIcon'; @@ -262,19 +263,7 @@ class TabbedSqlEditors extends React.PureComponent { '-- Note: Unless you save your query, these tabs will NOT persist if you clear your cookies or change browsers.\n\n', ); - let newTitle = 'Untitled Query 1'; - - if (this.props.queryEditors.length > 0) { - const untitledQueryNumbers = this.props.queryEditors - .filter(x => x.title.match(/^Untitled Query (\d+)$/)) - .map(x => x.title.replace('Untitled Query ', '')); - if (untitledQueryNumbers.length > 0) { - // When there are query tabs open, and at least one is called "Untitled Query #" - // Where # is a valid number - const largestNumber = Math.max.apply(null, untitledQueryNumbers); - newTitle = t('Untitled Query %s', largestNumber + 1); - } - } + const newTitle = newQueryTabName(this.props.queryEditors || []); const qe = { title: newTitle, diff --git a/superset-frontend/src/SqlLab/components/TemplateParamsEditor/TemplateParamsEditor.test.tsx b/superset-frontend/src/SqlLab/components/TemplateParamsEditor/TemplateParamsEditor.test.tsx index e663704ba21b8..bc04030d28c8e 100644 --- a/superset-frontend/src/SqlLab/components/TemplateParamsEditor/TemplateParamsEditor.test.tsx +++ b/superset-frontend/src/SqlLab/components/TemplateParamsEditor/TemplateParamsEditor.test.tsx @@ -24,7 +24,6 @@ import { getByText, waitFor, } from 'spec/helpers/testing-library'; -import brace from 'brace'; import { ThemeProvider, supersetTheme } from '@superset-ui/core'; import TemplateParamsEditor from 'src/SqlLab/components/TemplateParamsEditor'; @@ -48,8 +47,6 @@ describe('TemplateParamsEditor', () => { { wrapper: ThemeWrapper }, ); fireEvent.click(getByText(container, 'Parameters')); - const spy = jest.spyOn(brace, 'acequire'); - spy.mockReturnValue({ setCompleters: () => 'foo' }); await waitFor(() => { expect(baseElement.querySelector('#ace-editor')).toBeInTheDocument(); }); diff --git a/superset-frontend/src/SqlLab/components/TemplateParamsEditor/index.tsx b/superset-frontend/src/SqlLab/components/TemplateParamsEditor/index.tsx index 4bedbfcecce31..62d0a7209de1c 100644 --- a/superset-frontend/src/SqlLab/components/TemplateParamsEditor/index.tsx +++ b/superset-frontend/src/SqlLab/components/TemplateParamsEditor/index.tsx @@ -74,7 +74,6 @@ function TemplateParamsEditor({ syntax.

; + dbConnect: boolean; offline: boolean; queries: Query[]; queryEditors: QueryEditor[]; diff --git a/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/index.js b/superset-frontend/src/SqlLab/utils/newQueryTabName.test.ts similarity index 50% rename from superset-frontend/plugins/legacy-plugin-chart-force-directed/src/index.js rename to superset-frontend/src/SqlLab/utils/newQueryTabName.test.ts index 87eb07b45e696..d0d98c3cd5e29 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-force-directed/src/index.js +++ b/superset-frontend/src/SqlLab/utils/newQueryTabName.test.ts @@ -16,26 +16,31 @@ * specific language governing permissions and limitations * under the License. */ -import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core'; -import transformProps from './transformProps'; -import thumbnail from './images/thumbnail.png'; -import controlPanel from './controlPanel'; -const metadata = new ChartMetadata({ - credits: ['http://bl.ocks.org/d3noob/5141278'], - description: '', - name: t('Force-directed Graph'), - thumbnail, - useLegacyApi: true, -}); +import { newQueryTabName } from './newQueryTabName'; + +const emptyEditor = { + title: '', + schema: '', + autorun: false, + sql: '', + remoteId: null, +}; -export default class ForceDirectedChartPlugin extends ChartPlugin { - constructor() { - super({ - loadChart: () => import('./ReactForceDirected'), - metadata, - transformProps, - controlPanel, - }); - } -} +describe('newQueryTabName', () => { + it("should return default title if queryEditor's length is 0", () => { + const defaultTitle = 'default title'; + const title = newQueryTabName([], defaultTitle); + expect(title).toEqual(defaultTitle); + }); + it('should return next available number if there are unsaved editors', () => { + const untitledQueryText = 'Untitled Query'; + const unsavedEditors = [ + { ...emptyEditor, title: `${untitledQueryText} 1` }, + { ...emptyEditor, title: `${untitledQueryText} 2` }, + ]; + + const nextTitle = newQueryTabName(unsavedEditors); + expect(nextTitle).toEqual(`${untitledQueryText} 3`); + }); +}); diff --git a/superset-frontend/src/SqlLab/utils/newQueryTabName.ts b/superset-frontend/src/SqlLab/utils/newQueryTabName.ts new file mode 100644 index 0000000000000..a719a74af59af --- /dev/null +++ b/superset-frontend/src/SqlLab/utils/newQueryTabName.ts @@ -0,0 +1,49 @@ +/** + * 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 { QueryEditor } from '../types'; + +const untitledQueryRegex = /^Untitled Query (\d+)$/; // Literal notation isn't recompiled +const untitledQuery = 'Untitled Query '; + +export const newQueryTabName = ( + queryEditors: QueryEditor[], + initialTitle = `${untitledQuery}1`, +): string => { + const resultTitle = t(initialTitle); + + if (queryEditors.length > 0) { + const mappedUntitled = queryEditors.filter(qe => + qe.title.match(untitledQueryRegex), + ); + const untitledQueryNumbers = mappedUntitled.map( + qe => +qe.title.replace(untitledQuery, ''), + ); + if (untitledQueryNumbers.length > 0) { + // When there are query tabs open, and at least one is called "Untitled Query #" + // Where # is a valid number + const largestNumber: number = Math.max(...untitledQueryNumbers); + return t(`${untitledQuery}%s`, largestNumber + 1); + } + return resultTitle; + } + + return resultTitle; +}; diff --git a/superset-frontend/src/assets/images/filter-results.svg b/superset-frontend/src/assets/images/filter-results.svg new file mode 100644 index 0000000000000..770a54b34f37f --- /dev/null +++ b/superset-frontend/src/assets/images/filter-results.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + diff --git a/superset-frontend/src/assets/images/vector.svg b/superset-frontend/src/assets/images/vector.svg new file mode 100644 index 0000000000000..0bf9c39c6ccb0 --- /dev/null +++ b/superset-frontend/src/assets/images/vector.svg @@ -0,0 +1,21 @@ + + + + diff --git a/superset-frontend/src/assets/stylesheets/less/variables.less b/superset-frontend/src/assets/stylesheets/less/variables.less index 3f4fad5572708..e997f5fb78c96 100644 --- a/superset-frontend/src/assets/stylesheets/less/variables.less +++ b/superset-frontend/src/assets/stylesheets/less/variables.less @@ -48,6 +48,7 @@ @almost-black: #263238; @gray-dark: #484848; @gray-light: #e0e0e0; +@gray-light5: #666666; @gray: #879399; @gray-bg: #f7f7f7; @gray-heading: #a3a3a3; diff --git a/superset-frontend/src/assets/stylesheets/superset.less b/superset-frontend/src/assets/stylesheets/superset.less index 97db49ec4463a..0cf419b30d190 100644 --- a/superset-frontend/src/assets/stylesheets/superset.less +++ b/superset-frontend/src/assets/stylesheets/superset.less @@ -539,12 +539,11 @@ td.filtered { width: 100% !important; } -// Remove this when the jinja menu/navbar is replaced with react. -// This style already exists in that view +/* +Hides the logo while loading the page. +Emotion styles will take care of the correct styling +*/ .navbar-brand { - display: flex; - flex-direction: column; - justify-content: center; display: none; } diff --git a/superset-frontend/src/components/AlteredSliceTag/index.jsx b/superset-frontend/src/components/AlteredSliceTag/index.jsx index 181079a1172f8..3e2d21ab139ae 100644 --- a/superset-frontend/src/components/AlteredSliceTag/index.jsx +++ b/superset-frontend/src/components/AlteredSliceTag/index.jsx @@ -182,10 +182,7 @@ export default class AlteredSliceTag extends React.Component { renderTriggerNode() { return ( - + {t('Altered')} diff --git a/superset-frontend/src/components/AsyncAceEditor/index.tsx b/superset-frontend/src/components/AsyncAceEditor/index.tsx index 521ae357bed31..dc5a37a61460c 100644 --- a/superset-frontend/src/components/AsyncAceEditor/index.tsx +++ b/superset-frontend/src/components/AsyncAceEditor/index.tsx @@ -24,6 +24,7 @@ import { TextMode as OrigTextMode, } from 'brace'; import AceEditor, { IAceEditorProps } from 'react-ace'; +import { acequire } from 'ace-builds/src-noconflict/ace'; import AsyncEsmComponent, { PlaceholderProps, } from 'src/components/AsyncEsmComponent'; @@ -55,7 +56,7 @@ export interface AceCompleterKeyword extends AceCompleterKeywordData { /** * Async loaders to import brace modules. Must manually create call `import(...)` - * promises because webpack can only analyze asycn imports statically. + * promises because webpack can only analyze async imports statically. */ const aceModuleLoaders = { 'mode/sql': () => import('brace/mode/sql'), @@ -101,7 +102,6 @@ export default function AsyncAceEditor( }: AsyncAceEditorOptions = {}, ) { return AsyncEsmComponent(async () => { - const { default: ace } = await import('brace'); const { default: ReactAceEditor } = await import('react-ace'); await Promise.all(aceModules.map(x => aceModuleLoaders[x]())); @@ -126,7 +126,7 @@ export default function AsyncAceEditor( ref, ) { if (keywords) { - const langTools = ace.acequire('ace/ext/language_tools'); + const langTools = acequire('ace/ext/language_tools'); const completer = { getCompletions: ( editor: AceEditor, diff --git a/superset-frontend/src/components/Button/index.tsx b/superset-frontend/src/components/Button/index.tsx index ea8cd4cd3525c..b8e428d6ca3a6 100644 --- a/superset-frontend/src/components/Button/index.tsx +++ b/superset-frontend/src/components/Button/index.tsx @@ -56,6 +56,7 @@ export interface ButtonProps { | 'rightTop' | 'rightBottom'; onClick?: OnClickHandler; + onMouseDown?: OnClickHandler; disabled?: boolean; buttonStyle?: ButtonStyle; buttonSize?: 'default' | 'small' | 'xsmall'; @@ -66,6 +67,14 @@ export interface ButtonProps { cta?: boolean; loading?: boolean | { delay?: number | undefined } | undefined; showMarginRight?: boolean; + type?: + | 'default' + | 'text' + | 'link' + | 'primary' + | 'dashed' + | 'ghost' + | undefined; } export default function Button(props: ButtonProps) { diff --git a/superset-frontend/src/components/Chart/Chart.jsx b/superset-frontend/src/components/Chart/Chart.jsx index 0d2914522d75a..7df33d0c5d7cb 100644 --- a/superset-frontend/src/components/Chart/Chart.jsx +++ b/superset-frontend/src/components/Chart/Chart.jsx @@ -18,11 +18,10 @@ */ import PropTypes from 'prop-types'; import React from 'react'; -import { styled, logging, t } from '@superset-ui/core'; +import { styled, logging, t, ensureIsArray } from '@superset-ui/core'; import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants'; -import Button from 'src/components/Button'; import Loading from 'src/components/Loading'; import { EmptyStateBig } from 'src/components/EmptyState'; import ErrorBoundary from 'src/components/ErrorBoundary'; @@ -32,6 +31,7 @@ import { getUrlParam } from 'src/utils/urlUtils'; import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; import ChartRenderer from './ChartRenderer'; import { ChartErrorMessage } from './ChartErrorMessage'; +import { getChartRequiredFieldsMissingMessage } from '../../utils/getChartRequiredFieldsMissingMessage'; const propTypes = { annotationData: PropTypes.object, @@ -64,7 +64,7 @@ const propTypes = { chartStackTrace: PropTypes.string, queriesResponse: PropTypes.arrayOf(PropTypes.object), triggerQuery: PropTypes.bool, - refreshOverlayVisible: PropTypes.bool, + chartIsStale: PropTypes.bool, errorMessage: PropTypes.node, // dashboard callbacks addFilter: PropTypes.func, @@ -108,20 +108,8 @@ const Styles = styled.div` } `; -const RefreshOverlayWrapper = styled.div` - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; -`; - const MonospaceDiv = styled.div` font-family: ${({ theme }) => theme.typography.families.monospace}; - white-space: pre; word-break: break-word; overflow-x: auto; white-space: pre-wrap; @@ -255,34 +243,49 @@ class Chart extends React.PureComponent { chartAlert, chartStatus, errorMessage, - onQuery, - refreshOverlayVisible, + chartIsStale, queriesResponse = [], isDeactivatedViz = false, width, } = this.props; const isLoading = chartStatus === 'loading'; - const isFaded = refreshOverlayVisible && !errorMessage; this.renderContainerStartTime = Logger.getTimestamp(); if (chartStatus === 'failed') { return queriesResponse.map(item => this.renderErrorMessage(item)); } - if (errorMessage) { - const description = isFeatureEnabled( - FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP, - ) - ? t( - 'Drag and drop values into highlighted field(s) on the left control panel and run query', - ) - : t( - 'Select values in highlighted field(s) on the left control panel and run query', - ); + if (errorMessage && ensureIsArray(queriesResponse).length === 0) { return ( + ); + } + + if ( + !isLoading && + !chartAlert && + !errorMessage && + chartIsStale && + ensureIsArray(queriesResponse).length === 0 + ) { + return ( + + {t( + 'Click on "Create chart" button in the control panel on the left to preview a visualization or', + )}{' '} + + {t('click here')} + + . + + } image="chart.svg" /> ); @@ -300,25 +303,13 @@ class Chart extends React.PureComponent { height={height} width={width} > -
+
- - {!isLoading && !chartAlert && isFaded && ( - - - - )} - {isLoading && !isDeactivatedViz && } diff --git a/superset-frontend/src/components/Chart/ChartRenderer.jsx b/superset-frontend/src/components/Chart/ChartRenderer.jsx index b814b6fde6d36..45feb6ffd57ee 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.jsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.jsx @@ -30,6 +30,7 @@ const propTypes = { datasource: PropTypes.object, initialValues: PropTypes.object, formData: PropTypes.object.isRequired, + latestQueryFormData: PropTypes.object, labelColors: PropTypes.object, sharedLabelColors: PropTypes.object, height: PropTypes.number, @@ -42,7 +43,7 @@ const propTypes = { chartStatus: PropTypes.string, queriesResponse: PropTypes.arrayOf(PropTypes.object), triggerQuery: PropTypes.bool, - refreshOverlayVisible: PropTypes.bool, + chartIsStale: PropTypes.bool, // dashboard callbacks addFilter: PropTypes.func, setDataMask: PropTypes.func, @@ -58,6 +59,8 @@ const BLANK = {}; const BIG_NO_RESULT_MIN_WIDTH = 300; const BIG_NO_RESULT_MIN_HEIGHT = 220; +const behaviors = [Behavior.INTERACTIVE_CHART]; + const defaultProps = { addFilter: () => BLANK, onFilterMenuOpen: () => BLANK, @@ -93,8 +96,7 @@ class ChartRenderer extends React.Component { const resultsReady = nextProps.queriesResponse && ['success', 'rendered'].indexOf(nextProps.chartStatus) > -1 && - !nextProps.queriesResponse?.[0]?.error && - !nextProps.refreshOverlayVisible; + !nextProps.queriesResponse?.[0]?.error; if (resultsReady) { this.hasQueryResponseChange = @@ -170,16 +172,10 @@ class ChartRenderer extends React.Component { } render() { - const { chartAlert, chartStatus, vizType, chartId, refreshOverlayVisible } = - this.props; + const { chartAlert, chartStatus, chartId } = this.props; // Skip chart rendering - if ( - refreshOverlayVisible || - chartStatus === 'loading' || - !!chartAlert || - chartStatus === null - ) { + if (chartStatus === 'loading' || !!chartAlert || chartStatus === null) { return null; } @@ -193,11 +189,17 @@ class ChartRenderer extends React.Component { initialValues, ownState, filterState, + chartIsStale, formData, + latestQueryFormData, queriesResponse, postTransformProps, } = this.props; + const currentFormData = + chartIsStale && latestQueryFormData ? latestQueryFormData : formData; + const vizType = currentFormData.viz_type || this.props.vizType; + // It's bad practice to use unprefixed `vizType` as classnames for chart // container. It may cause css conflicts as in the case of legacy table chart. // When migrating charts, we should gradually add a `superset-chart-` prefix @@ -255,11 +257,11 @@ class ChartRenderer extends React.Component { annotationData={annotationData} datasource={datasource} initialValues={initialValues} - formData={formData} + formData={currentFormData} ownState={ownState} filterState={filterState} hooks={this.hooks} - behaviors={[Behavior.INTERACTIVE_CHART]} + behaviors={behaviors} queriesData={queriesResponse} onRenderSuccess={this.handleRenderSuccess} onRenderFailure={this.handleRenderFailure} diff --git a/superset-frontend/src/components/Chart/ChartRenderer.test.jsx b/superset-frontend/src/components/Chart/ChartRenderer.test.jsx index 7e3a455631ff0..f3ce0415175fb 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.test.jsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.test.jsx @@ -25,22 +25,25 @@ import ChartRenderer from 'src/components/Chart/ChartRenderer'; const requiredProps = { chartId: 1, datasource: {}, - formData: {}, - vizType: 'foo', + formData: { testControl: 'foo' }, + latestQueryFormData: { + testControl: 'bar', + }, + vizType: 'table', }; describe('ChartRenderer', () => { it('should render SuperChart', () => { const wrapper = shallow( - , + , ); expect(wrapper.find(SuperChart)).toExist(); }); - it('should not render SuperChart when refreshOverlayVisible is true', () => { - const wrapper = shallow( - , - ); - expect(wrapper.find(SuperChart)).not.toExist(); + it('should use latestQueryFormData instead of formData when chartIsStale is true', () => { + const wrapper = shallow(); + expect(wrapper.find(SuperChart).prop('formData')).toEqual({ + testControl: 'bar', + }); }); }); diff --git a/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx b/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx index 2387c2e2517fe..272249b549600 100644 --- a/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx +++ b/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx @@ -21,11 +21,12 @@ import React from 'react'; import { render, screen, waitFor } from 'spec/helpers/testing-library'; import { SupersetClient } from '@superset-ui/core'; import userEvent from '@testing-library/user-event'; -import DatabaseSelector from '.'; +import DatabaseSelector, { DatabaseSelectorProps } from '.'; +import { EmptyStateSmall } from '../EmptyState'; const SupersetClientGet = jest.spyOn(SupersetClient, 'get'); -const createProps = () => ({ +const createProps = (): DatabaseSelectorProps => ({ db: { id: 1, database_name: 'test', @@ -38,12 +39,10 @@ const createProps = () => ({ schema: undefined, sqlLabMode: true, getDbList: jest.fn(), - getTableList: jest.fn(), handleError: jest.fn(), onDbChange: jest.fn(), onSchemaChange: jest.fn(), onSchemasLoad: jest.fn(), - onUpdate: jest.fn(), }); beforeEach(() => { @@ -191,12 +190,10 @@ test('Refresh should work', async () => { await waitFor(() => { expect(SupersetClientGet).toBeCalledTimes(2); expect(props.getDbList).toBeCalledTimes(0); - expect(props.getTableList).toBeCalledTimes(0); expect(props.handleError).toBeCalledTimes(0); expect(props.onDbChange).toBeCalledTimes(0); expect(props.onSchemaChange).toBeCalledTimes(0); expect(props.onSchemasLoad).toBeCalledTimes(0); - expect(props.onUpdate).toBeCalledTimes(0); }); userEvent.click(screen.getByRole('button', { name: 'refresh' })); @@ -204,12 +201,10 @@ test('Refresh should work', async () => { await waitFor(() => { expect(SupersetClientGet).toBeCalledTimes(3); expect(props.getDbList).toBeCalledTimes(1); - expect(props.getTableList).toBeCalledTimes(0); expect(props.handleError).toBeCalledTimes(0); expect(props.onDbChange).toBeCalledTimes(0); expect(props.onSchemaChange).toBeCalledTimes(0); expect(props.onSchemasLoad).toBeCalledTimes(2); - expect(props.onUpdate).toBeCalledTimes(0); }); }); @@ -224,6 +219,28 @@ test('Should database select display options', async () => { expect(await screen.findByText('test-mysql')).toBeInTheDocument(); }); +test('should show empty state if there are no options', async () => { + SupersetClientGet.mockImplementation( + async () => ({ json: { result: [] } } as any), + ); + const props = createProps(); + render( + } + />, + { useRedux: true }, + ); + const select = screen.getByRole('combobox', { + name: 'Select database or type database name', + }); + userEvent.click(select); + const emptystate = await screen.findByText('empty'); + expect(emptystate).toBeInTheDocument(); + expect(screen.queryByText('test-mysql')).not.toBeInTheDocument(); +}); + test('Should schema select display options', async () => { const props = createProps(); render(, { useRedux: true }); diff --git a/superset-frontend/src/components/DatabaseSelector/index.tsx b/superset-frontend/src/components/DatabaseSelector/index.tsx index 531a7a9e7194c..718177a13956f 100644 --- a/superset-frontend/src/components/DatabaseSelector/index.tsx +++ b/superset-frontend/src/components/DatabaseSelector/index.tsx @@ -86,13 +86,15 @@ export type DatabaseObject = { type SchemaValue = { label: string; value: string }; -interface DatabaseSelectorProps { +export interface DatabaseSelectorProps { db?: DatabaseObject; + emptyState?: ReactNode; formMode?: boolean; getDbList?: (arg0: any) => {}; handleError: (msg: string) => void; isDatabaseSelectEnabled?: boolean; onDbChange?: (db: DatabaseObject) => void; + onEmptyResults?: (searchText?: string) => void; onSchemaChange?: (schema?: string) => void; onSchemasLoad?: (schemas: Array) => void; readOnly?: boolean; @@ -118,10 +120,12 @@ const SelectLabel = ({ export default function DatabaseSelector({ db, formMode = false, + emptyState, getDbList, handleError, isDatabaseSelectEnabled = true, onDbChange, + onEmptyResults, onSchemaChange, onSchemasLoad, readOnly = false, @@ -146,6 +150,7 @@ export default function DatabaseSelector({ ); const [refresh, setRefresh] = useState(0); const { addSuccessToast } = useToasts(); + const loadDatabases = useMemo( () => async ( @@ -181,7 +186,7 @@ export default function DatabaseSelector({ getDbList(result); } if (result.length === 0) { - handleError(t("It seems you don't have access to any database")); + if (onEmptyResults) onEmptyResults(search); } const options = result.map((row: DatabaseObject) => ({ label: ( @@ -197,13 +202,14 @@ export default function DatabaseSelector({ allow_multi_schema_metadata_fetch: row.allow_multi_schema_metadata_fetch, })); + return { data: options, totalCount: options.length, }; }); }, - [formMode, getDbList, handleError, sqlLabMode], + [formMode, getDbList, sqlLabMode], ); useEffect(() => { @@ -272,6 +278,7 @@ export default function DatabaseSelector({ data-test="select-database" header={{t('Database')}} lazyLoading={false} + notFoundContent={emptyState} onChange={changeDataBase} value={currentDb} placeholder={t('Select database or type database name')} @@ -289,11 +296,10 @@ export default function DatabaseSelector({ tooltipContent={t('Force refresh schema list')} /> ); - return renderSelectRow( onTimezoneChange(tz as string)} value={validTimezone} options={TIMEZONE_OPTIONS} diff --git a/superset-frontend/src/components/URLShortLinkButton/index.jsx b/superset-frontend/src/components/URLShortLinkButton/index.jsx index 35795f81a11fa..4a03e02d3ea5a 100644 --- a/superset-frontend/src/components/URLShortLinkButton/index.jsx +++ b/superset-frontend/src/components/URLShortLinkButton/index.jsx @@ -57,11 +57,11 @@ class URLShortLinkButton extends React.Component { if (this.props.dashboardId) { getFilterValue(this.props.dashboardId, nativeFiltersKey) .then(filterState => - getDashboardPermalink( - String(this.props.dashboardId), + getDashboardPermalink({ + dashboardId: this.props.dashboardId, filterState, - this.props.anchorLinkId, - ) + hash: this.props.anchorLinkId, + }) .then(this.onShortUrlSuccess) .catch(this.props.addDangerToast), ) diff --git a/superset-frontend/src/dashboard/actions/dashboardLayout.js b/superset-frontend/src/dashboard/actions/dashboardLayout.js index e0cbe7aa00c77..8d4b3fa56c944 100644 --- a/superset-frontend/src/dashboard/actions/dashboardLayout.js +++ b/superset-frontend/src/dashboard/actions/dashboardLayout.js @@ -210,7 +210,7 @@ export function handleComponentDrop(dropResult) { destination.id !== rootChildId ) { return dispatch( - addWarningToast(t(`Can not move top level tab into nested tabs`)), + addWarningToast(t('Can not move top level tab into nested tabs')), ); } else if ( destination && diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx index 4f7ef563ce55f..8352482ed8ab8 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx @@ -18,7 +18,14 @@ */ /* eslint-env browser */ import cx from 'classnames'; -import React, { FC, useCallback, useMemo } from 'react'; +import React, { + FC, + useCallback, + useEffect, + useState, + useMemo, + useRef, +} from 'react'; import { JsonObject, styled, css, t } from '@superset-ui/core'; import { Global } from '@emotion/react'; import { useDispatch, useSelector } from 'react-redux'; @@ -34,7 +41,10 @@ import getDirectPathToTabIndex from 'src/dashboard/util/getDirectPathToTabIndex' import { URL_PARAMS } from 'src/constants'; import { getUrlParam } from 'src/utils/urlUtils'; import { DashboardLayout, RootState } from 'src/dashboard/types'; -import { setDirectPathToChild } from 'src/dashboard/actions/dashboardState'; +import { + setDirectPathToChild, + setEditMode, +} from 'src/dashboard/actions/dashboardState'; import { useElementOnScreen } from 'src/hooks/useElementOnScreen'; import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags'; import { @@ -56,10 +66,8 @@ import { CLOSED_FILTER_BAR_WIDTH, FILTER_BAR_HEADER_HEIGHT, FILTER_BAR_TABS_HEIGHT, - HEADER_HEIGHT, MAIN_HEADER_HEIGHT, OPEN_FILTER_BAR_WIDTH, - TABS_HEIGHT, } from 'src/dashboard/constants'; import { shouldFocusTabs, getRootLevelTabsComponent } from './utils'; import DashboardContainer from './DashboardContainer'; @@ -249,6 +257,7 @@ const DashboardBuilder: FC = () => { [dispatch], ); + const headerRef = React.useRef(null); const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID]; const rootChildId = dashboardRoot?.children[0]; const topLevelTabs = @@ -261,10 +270,26 @@ const DashboardBuilder: FC = () => { uiConfig.hideTitle || standaloneMode === DashboardStandaloneMode.HIDE_NAV_AND_TITLE || isReport; + const [barTopOffset, setBarTopOffset] = useState(0); + + useEffect(() => { + setBarTopOffset(headerRef.current?.getBoundingClientRect()?.height || 0); + + let observer: ResizeObserver; + if (typeof global.ResizeObserver !== 'undefined' && headerRef.current) { + observer = new ResizeObserver(entries => { + setBarTopOffset( + current => entries?.[0]?.contentRect?.height || current, + ); + }); - const barTopOffset = - (hideDashboardHeader ? 0 : HEADER_HEIGHT) + - (topLevelTabs ? TABS_HEIGHT : 0); + observer.observe(headerRef.current); + } + + return () => { + observer?.disconnect(); + }; + }, []); const { showDashboard, @@ -301,6 +326,27 @@ const DashboardBuilder: FC = () => { [dashboardFiltersOpen, editMode, nativeFiltersEnabled], ); + // If a new tab was added, update the directPathToChild to reflect it + const currentTopLevelTabs = useRef(topLevelTabs); + useEffect(() => { + const currentTabsLength = currentTopLevelTabs.current?.children?.length; + const newTabsLength = topLevelTabs?.children?.length; + + if ( + currentTabsLength !== undefined && + newTabsLength !== undefined && + newTabsLength > currentTabsLength + ) { + const lastTab = getDirectPathToTabIndex( + getRootLevelTabsComponent(dashboardLayout), + newTabsLength - 1, + ); + dispatch(setDirectPathToChild(lastTab)); + } + + currentTopLevelTabs.current = topLevelTabs; + }, [topLevelTabs]); + const renderDraggableContent = useCallback( ({ dropIndicatorProps }: { dropIndicatorProps: JsonObject }) => (
@@ -361,7 +407,7 @@ const DashboardBuilder: FC = () => { )} - + {/* @ts-ignore */} = () => { 'Go to the edit mode to configure the dashboard and add charts', ) } + buttonText={canEdit && t('Edit the dashboard')} + buttonAction={() => dispatch(setEditMode(true))} image="dashboard.svg" /> )} diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx index b08a7cd6339f5..c763f07267f55 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx @@ -18,7 +18,7 @@ */ // ParentSize uses resize observer so the dashboard will update size // when its container size changes, due to e.g., builder side panel opening -import React, { FC, useEffect, useMemo, useState } from 'react'; +import React, { FC, useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { FeatureFlag, @@ -36,7 +36,6 @@ import { LayoutItem, RootState, } from 'src/dashboard/types'; -import getLeafComponentIdFromPath from 'src/dashboard/util/getLeafComponentIdFromPath'; import { DASHBOARD_GRID_ID, DASHBOARD_ROOT_DEPTH, @@ -68,29 +67,27 @@ const useNativeFilterScopes = () => { }; const DashboardContainer: FC = ({ topLevelTabs }) => { + const nativeFilterScopes = useNativeFilterScopes(); + const dispatch = useDispatch(); + const dashboardLayout = useSelector( state => state.dashboardLayout.present, ); - const nativeFilterScopes = useNativeFilterScopes(); const directPathToChild = useSelector( state => state.dashboardState.directPathToChild, ); const charts = useSelector(state => state.charts); - const [tabIndex, setTabIndex] = useState( - getRootLevelTabIndex(dashboardLayout, directPathToChild), - ); - const dispatch = useDispatch(); - - useEffect(() => { + const tabIndex = useMemo(() => { const nextTabIndex = findTabIndexByComponentId({ currentComponent: getRootLevelTabsComponent(dashboardLayout), directPathToChild, }); - if (nextTabIndex > -1) { - setTabIndex(nextTabIndex); - } - }, [getLeafComponentIdFromPath(directPathToChild)]); + + return nextTabIndex > -1 + ? nextTabIndex + : getRootLevelTabIndex(dashboardLayout, directPathToChild); + }, [dashboardLayout, directPathToChild]); useEffect(() => { if ( diff --git a/superset-frontend/src/dashboard/components/DashboardEmbedControls.tsx b/superset-frontend/src/dashboard/components/DashboardEmbedControls.tsx index d20cc2460090b..a2c44f490243f 100644 --- a/superset-frontend/src/dashboard/components/DashboardEmbedControls.tsx +++ b/superset-frontend/src/dashboard/components/DashboardEmbedControls.tsx @@ -17,7 +17,13 @@ * under the License. */ import React, { useCallback, useEffect, useState } from 'react'; -import { makeApi, styled, SupersetApiError, t } from '@superset-ui/core'; +import { + makeApi, + styled, + SupersetApiError, + t, + getUiOverrideRegistry, +} from '@superset-ui/core'; import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; import Modal from 'src/components/Modal'; import Loading from 'src/components/Loading'; @@ -27,6 +33,8 @@ import { useToasts } from 'src/components/MessageToasts/withToasts'; import { FormItem } from 'src/components/Form'; import { EmbeddedDashboard } from '../types'; +const uiOverrideRegistry = getUiOverrideRegistry(); + type Props = { dashboardId: string; show: boolean; @@ -140,6 +148,13 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => { return ; } + const docsDescription = uiOverrideRegistry.get( + 'embedded.documentation.description', + ); + const docsUrl = + uiOverrideRegistry.get('embedded.documentation.url') ?? + 'https://www.npmjs.com/package/@superset-ui/embedded-sdk'; + return ( <>

@@ -159,12 +174,10 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {

{t('For further instructions, consult the')}{' '} - - {t('Superset Embedded SDK documentation.')} + + {docsDescription + ? docsDescription() + : t('Superset Embedded SDK documentation.')}

Settings

diff --git a/superset-frontend/src/dashboard/components/DashboardGrid.jsx b/superset-frontend/src/dashboard/components/DashboardGrid.jsx index 5a9d2ff812786..4be8d6bc05d0f 100644 --- a/superset-frontend/src/dashboard/components/DashboardGrid.jsx +++ b/superset-frontend/src/dashboard/components/DashboardGrid.jsx @@ -24,6 +24,7 @@ import { componentShape } from '../util/propShapes'; import DashboardComponent from '../containers/DashboardComponent'; import DragDroppable from './dnd/DragDroppable'; import { GRID_GUTTER_SIZE, GRID_COLUMN_COUNT } from '../util/constants'; +import { TAB_TYPE } from '../util/componentTypes'; const propTypes = { depth: PropTypes.number.isRequired, @@ -137,9 +138,11 @@ class DashboardGrid extends React.PureComponent { gridComponent, handleComponentDrop, depth, - editMode, width, isComponentVisible, + editMode, + canEdit, + setEditMode, } = this.props; const columnPlusGutterWidth = (width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT; @@ -147,26 +150,70 @@ class DashboardGrid extends React.PureComponent { const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE; const { isResizing, rowGuideTop } = this.state; + const shouldDisplayEmptyState = gridComponent?.children?.length === 0; + const shouldDisplayTopLevelTabEmptyState = + shouldDisplayEmptyState && gridComponent.type === TAB_TYPE; + + const dashboardEmptyState = editMode && ( + + + {t('Create a new chart')} + + } + buttonAction={() => { + window.open('/chart/add', '_blank', 'noopener noreferrer'); + }} + image="chart.svg" + /> + ); + + const topLevelTabEmptyState = editMode ? ( + + + {t('Create a new chart')} + + } + buttonAction={() => { + window.open('/chart/add', '_blank', 'noopener noreferrer'); + }} + image="chart.svg" + /> + ) : ( + { + setEditMode(true); + }) + } + image="chart.svg" + /> + ); + return width < 100 ? null : ( <> - {editMode && gridComponent?.children?.length === 0 && ( + {shouldDisplayEmptyState && ( - - - {t('Create a new chart')} - - } - buttonAction={() => { - window.location.assign('/chart/add'); - }} - image="chart.svg" - /> + {shouldDisplayTopLevelTabEmptyState + ? topLevelTabEmptyState + : dashboardEmptyState} )}
diff --git a/superset-frontend/src/dashboard/components/SliceHeader/SliceHeader.test.tsx b/superset-frontend/src/dashboard/components/SliceHeader/SliceHeader.test.tsx index b1a2efc7b87ad..fd5892f427635 100644 --- a/superset-frontend/src/dashboard/components/SliceHeader/SliceHeader.test.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeader/SliceHeader.test.tsx @@ -157,6 +157,8 @@ const createProps = () => ({ exportCSV: jest.fn(), onExploreChart: jest.fn(), formData: { slice_id: 1, datasource: '58__table' }, + width: 100, + height: 100, }); test('Should render', () => { diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx index 88e9a4c69f300..44ddf7b5adef8 100644 --- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import React, { FC, useMemo } from 'react'; +import React, { FC, useEffect, useMemo, useRef, useState } from 'react'; import { styled, t } from '@superset-ui/core'; import { useUiConfig } from 'src/components/UiConfigContext'; import { Tooltip } from 'src/components/Tooltip'; @@ -41,6 +41,8 @@ type SliceHeaderProps = SliceHeaderControlsProps & { filters: object; handleToggleFullSize: () => void; formData: object; + width: number; + height: number; }; const annotationsLoading = t('Annotation layers are still loading.'); @@ -82,9 +84,13 @@ const SliceHeader: FC = ({ isFullSize, chartStatus, formData, + width, + height, }) => { const dispatch = useDispatch(); const uiConfig = useUiConfig(); + const [headerTooltip, setHeaderTooltip] = useState(null); + const headerRef = useRef(null); // TODO: change to indicator field after it will be implemented const crossFilterValue = useSelector( state => state.dataMask[slice?.slice_id]?.filterState?.value, @@ -98,21 +104,36 @@ const SliceHeader: FC = ({ [crossFilterValue], ); + useEffect(() => { + const headerElement = headerRef.current; + if ( + headerElement && + (headerElement.scrollWidth > headerElement.offsetWidth || + headerElement.scrollHeight > headerElement.offsetHeight) + ) { + setHeaderTooltip(sliceName ?? null); + } else { + setHeaderTooltip(null); + } + }, [sliceName, width, height]); + return (
-
- +
+ + + {!!Object.values(annotationQuery).length && ( {}, supersetCanShare = false, isCached = [], - formData, } = this.props; const crossFilterItems = getChartMetadataRegistry().items; const isTable = slice.viz_type === 'table'; @@ -310,13 +311,14 @@ class SliceHeaderControls extends React.PureComponent< {supersetCanShare && ( )} diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx index 134e026d7a38f..b212af44e5b0c 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx @@ -113,6 +113,12 @@ const ChartOverlay = styled.div` } `; +const SliceContainer = styled.div` + display: flex; + flex-direction: column; + max-height: 100%; +`; + export default class Chart extends React.Component { constructor(props) { super(props); @@ -210,7 +216,10 @@ export default class Chart extends React.Component { getChartHeight() { const headerHeight = this.getHeaderHeight(); - return this.state.height - headerHeight - this.state.descriptionHeight; + return Math.max( + this.state.height - headerHeight - this.state.descriptionHeight, + 20, + ); } getHeaderHeight() { @@ -370,7 +379,7 @@ export default class Chart extends React.Component { }) : {}; return ( -
{/* @@ -468,7 +479,7 @@ export default class Chart extends React.Component { datasetsStatus={datasetsStatus} />
-
+ ); } } diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tab.jsx index cf051baefbb90..77156278504d6 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tab.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tab.jsx @@ -18,8 +18,12 @@ */ import React from 'react'; import PropTypes from 'prop-types'; -import { styled } from '@superset-ui/core'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { styled, t } from '@superset-ui/core'; +import { EmptyStateMedium } from 'src/components/EmptyState'; +import { setEditMode } from 'src/dashboard/actions/dashboardState'; import DashboardComponent from '../../containers/DashboardComponent'; import DragDroppable from '../dnd/DragDroppable'; import EditableTitle from '../../../components/EditableTitle'; @@ -40,6 +44,7 @@ const propTypes = { renderType: PropTypes.oneOf([RENDER_TAB, RENDER_TAB_CONTENT]).isRequired, onDropOnTab: PropTypes.func, editMode: PropTypes.bool.isRequired, + canEdit: PropTypes.bool.isRequired, filters: PropTypes.object.isRequired, // grid related @@ -53,6 +58,7 @@ const propTypes = { handleComponentDrop: PropTypes.func.isRequired, updateComponents: PropTypes.func.isRequired, setDirectPathToChild: PropTypes.func.isRequired, + setEditMode: PropTypes.func.isRequired, }; const defaultProps = { @@ -85,7 +91,7 @@ const renderDraggableContentTop = dropProps =>
); -export default class Tab extends React.PureComponent { +class Tab extends React.PureComponent { constructor(props) { super(props); this.handleChangeText = this.handleChangeText.bind(this); @@ -143,8 +149,11 @@ export default class Tab extends React.PureComponent { onResizeStop, editMode, isComponentVisible, + canEdit, + setEditMode, } = this.props; + const shouldDisplayEmptyState = tabComponent.children.length === 0; return (
{/* Make top of tab droppable */} @@ -162,6 +171,43 @@ export default class Tab extends React.PureComponent { {renderDraggableContentTop} )} + {shouldDisplayEmptyState && ( + + {t('You can')}{' '} + + {t('create a new chart')} + {' '} + {t('or use existing ones from the panel on the right')} + + ) : ( + + {t('You can add the components in the')}{' '} + setEditMode(true)} + > + {t('edit mode')} + + + )) + } + image="chart.svg" + /> + )} {tabComponent.children.map((componentId, componentIndex) => ( ); }), ); +jest.mock('src/dashboard/actions/dashboardState', () => ({ + setEditMode: jest.fn(() => ({ + type: 'SET_EDIT_MODE', + })), +})); -const creteProps = () => ({ +const createProps = () => ({ id: 'TAB-YT6eNksV-', parentId: 'TABS-L-d9eyOE-b', depth: 2, @@ -98,7 +104,7 @@ beforeEach(() => { }); test('Render tab (no content)', () => { - const props = creteProps(); + const props = createProps(); props.renderType = 'RENDER_TAB'; render(, { useRedux: true, useDnd: true }); expect(screen.getByText('🚀 Aspiring Developers')).toBeInTheDocument(); @@ -107,7 +113,7 @@ test('Render tab (no content)', () => { }); test('Render tab (no content) editMode:true', () => { - const props = creteProps(); + const props = createProps(); props.editMode = true; props.renderType = 'RENDER_TAB'; render(, { useRedux: true, useDnd: true }); @@ -117,7 +123,7 @@ test('Render tab (no content) editMode:true', () => { }); test('Edit table title', () => { - const props = creteProps(); + const props = createProps(); props.editMode = true; props.renderType = 'RENDER_TAB'; render(, { useRedux: true, useDnd: true }); @@ -131,7 +137,7 @@ test('Edit table title', () => { }); test('Render tab (with content)', () => { - const props = creteProps(); + const props = createProps(); props.isFocused = true; render(, { useRedux: true, useDnd: true }); expect(DashboardComponent).toBeCalledTimes(2); @@ -174,8 +180,39 @@ test('Render tab (with content)', () => { expect(DragDroppable).toBeCalledTimes(0); }); +test('Render tab content with no children', () => { + const props = createProps(); + props.component.children = []; + render(, { + useRedux: true, + useDnd: true, + }); + expect( + screen.getByText('There are no components added to this tab'), + ).toBeVisible(); + expect(screen.getByAltText('empty')).toBeVisible(); + expect(screen.queryByText('edit mode')).not.toBeInTheDocument(); +}); + +test('Render tab content with no children, canEdit: true', () => { + const props = createProps(); + props.component.children = []; + render(, { + useRedux: true, + useDnd: true, + initialState: { + dashboardInfo: { + dash_edit_perm: true, + }, + }, + }); + expect(screen.getByText('edit mode')).toBeVisible(); + userEvent.click(screen.getByRole('button', { name: 'edit mode' })); + expect(setEditMode).toHaveBeenCalled(); +}); + test('Render tab (with content) editMode:true', () => { - const props = creteProps(); + const props = createProps(); props.isFocused = true; props.editMode = true; render(, { useRedux: true, useDnd: true }); @@ -220,7 +257,7 @@ test('Render tab (with content) editMode:true', () => { }); test('Should call "handleDrop" and "handleTopDropTargetDrop"', () => { - const props = creteProps(); + const props = createProps(); props.isFocused = true; props.editMode = true; render(, { useRedux: true, useDnd: true }); @@ -233,3 +270,29 @@ test('Should call "handleDrop" and "handleTopDropTargetDrop"', () => { expect(props.onDropOnTab).toBeCalledTimes(1); expect(props.handleComponentDrop).toBeCalledTimes(2); }); + +test('Render tab content with no children, editMode: true, canEdit: true', () => { + const props = createProps(); + props.editMode = true; + // props.canEdit = true; + props.component.children = []; + render(, { + useRedux: true, + useDnd: true, + initialState: { + dashboardInfo: { + dash_edit_perm: true, + }, + }, + }); + expect( + screen.getByText('Drag and drop components to this tab'), + ).toBeVisible(); + expect(screen.getByAltText('empty')).toBeVisible(); + expect( + screen.getByRole('link', { name: 'create a new chart' }), + ).toBeVisible(); + expect( + screen.getByRole('link', { name: 'create a new chart' }), + ).toHaveAttribute('href', '/chart/add'); +}); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx index 8f2643533f3e4..c579abb911b15 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx @@ -155,22 +155,6 @@ export class Tabs extends React.PureComponent { this.setState(() => ({ tabIndex: maxIndex })); } - if (nextTabsIds.length) { - const lastTabId = nextTabsIds[nextTabsIds.length - 1]; - // if a new tab is added focus on it immediately - if (nextTabsIds.length > currTabsIds.length) { - // a new tab's path may be empty, here also need to set tabIndex - this.setState(() => ({ - activeKey: lastTabId, - tabIndex: maxIndex, - })); - } - // if a tab is removed focus on the first - if (nextTabsIds.length < currTabsIds.length) { - this.setState(() => ({ activeKey: nextTabsIds[0] })); - } - } - if (nextProps.isComponentVisible) { const nextFocusComponent = getLeafComponentIdFromPath( nextProps.directPathToChild, @@ -179,7 +163,14 @@ export class Tabs extends React.PureComponent { this.props.directPathToChild, ); - if (nextFocusComponent !== currentFocusComponent) { + // If the currently selected component is different than the new one, + // or the tab length/order changed, calculate the new tab index and + // replace it if it's different than the current one + if ( + nextFocusComponent !== currentFocusComponent || + (nextFocusComponent === currentFocusComponent && + currTabsIds !== nextTabsIds) + ) { const nextTabIndex = findTabIndexByComponentId({ currentComponent: nextProps.component, directPathToChild: nextProps.directPathToChild, @@ -219,9 +210,12 @@ export class Tabs extends React.PureComponent { }); }; - handleEdit = (key, action) => { + handleEdit = (event, action) => { const { component, createComponent } = this.props; if (action === 'add') { + // Prevent the tab container to be selected + event?.stopPropagation?.(); + createComponent({ destination: { id: component.id, @@ -234,7 +228,7 @@ export class Tabs extends React.PureComponent { }, }); } else if (action === 'remove') { - this.showDeleteConfirmModal(key); + this.showDeleteConfirmModal(event); } }; @@ -261,7 +255,11 @@ export class Tabs extends React.PureComponent { } handleDeleteTab(tabIndex) { - this.handleClickTab(Math.max(0, tabIndex - 1)); + // If we're removing the currently selected tab, + // select the previous one (if any) + if (this.state.tabIndex === tabIndex) { + this.handleClickTab(Math.max(0, tabIndex - 1)); + } } handleDropOnTab(dropResult) { diff --git a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx index c70e47dc3d01d..b196100734cc3 100644 --- a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx +++ b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx @@ -18,14 +18,10 @@ */ import React from 'react'; import copyTextToClipboard from 'src/utils/copy'; -import { t, logging, QueryFormData } from '@superset-ui/core'; +import { t, logging } from '@superset-ui/core'; import { Menu } from 'src/components/Menu'; -import { - getChartPermalink, - getDashboardPermalink, - getUrlParam, -} from 'src/utils/urlUtils'; -import { RESERVED_DASHBOARD_URL_PARAMS, URL_PARAMS } from 'src/constants'; +import { getDashboardPermalink, getUrlParam } from 'src/utils/urlUtils'; +import { URL_PARAMS } from 'src/constants'; import { getFilterValue } from 'src/dashboard/components/nativeFilters/FilterBar/keyValue'; interface ShareMenuItemProps { @@ -36,8 +32,8 @@ interface ShareMenuItemProps { emailBody: string; addDangerToast: Function; addSuccessToast: Function; - dashboardId?: string; - formData?: Pick; + dashboardId: string | number; + dashboardComponentId?: string; } const ShareMenuItems = (props: ShareMenuItemProps) => { @@ -49,23 +45,21 @@ const ShareMenuItems = (props: ShareMenuItemProps) => { addDangerToast, addSuccessToast, dashboardId, - formData, + dashboardComponentId, ...rest } = props; async function generateUrl() { - // chart - if (formData) { - // we need to remove reserved dashboard url params - return getChartPermalink(formData, RESERVED_DASHBOARD_URL_PARAMS); - } - // dashboard const nativeFiltersKey = getUrlParam(URL_PARAMS.nativeFiltersKey); let filterState = {}; if (nativeFiltersKey && dashboardId) { filterState = await getFilterValue(dashboardId, nativeFiltersKey); } - return getDashboardPermalink(String(dashboardId), filterState); + return getDashboardPermalink({ + dashboardId, + filterState, + hash: dashboardComponentId, + }); } async function onCopyLink() { diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx index 632f8978efa2f..de7d6af99ca09 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx @@ -88,7 +88,8 @@ const addFilterFlow = async () => { userEvent.click(screen.getByText('Time range')); userEvent.type(screen.getByTestId(getModalTestId('name-input')), FILTER_NAME); userEvent.click(screen.getByText('Save')); - await screen.findByText('All filters (1)'); + // TODO: fix this flaky test + // await screen.findByText('All filters (1)'); }; const addFilterSetFlow = async () => { 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 50fb6e8ffe761..ff04e0ebe593c 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx @@ -73,7 +73,10 @@ import { waitForAsyncData } from 'src/middleware/asyncEvent'; import { cacheWrapper } from 'src/utils/cacheWrapper'; import { ClientErrorObject } from 'src/utils/getClientErrorObject'; import { SingleValueType } from 'src/filters/components/Range/SingleValueType'; -import { getFormData } from 'src/dashboard/components/nativeFilters/utils'; +import { + getFormData, + mergeExtraFormData, +} from 'src/dashboard/components/nativeFilters/utils'; import { ALLOW_DEPENDENCIES as TYPES_SUPPORT_DEPENDENCIES, getFiltersConfigModalTestId, @@ -346,10 +349,11 @@ const FiltersConfigForm = ( const forceUpdate = useForceUpdate(); const [datasetDetails, setDatasetDetails] = useState>(); const defaultFormFilter = useMemo(() => ({}), []); - const formValues = form.getFieldValue('filters')?.[filterId]; + const filters = form.getFieldValue('filters'); + const formValues = filters?.[filterId]; const formFilter = formValues || undoFormValues || defaultFormFilter; - const dependencies = + const dependencies: string[] = formFilter?.dependencies || filterToEdit?.cascadeParentIds; const nativeFilterItems = getChartMetadataRegistry().items; @@ -439,6 +443,21 @@ const FiltersConfigForm = ( forceUpdate(); }; + // Calculates the dependencies default values to be used + // to extract the available values to the filter + let dependenciesDefaultValues = {}; + if (dependencies && dependencies.length > 0 && filters) { + dependencies.forEach(dependency => { + const extraFormData = filters[dependency]?.defaultDataMask?.extraFormData; + dependenciesDefaultValues = mergeExtraFormData( + dependenciesDefaultValues, + extraFormData, + ); + }); + } + + const dependenciesText = JSON.stringify(dependenciesDefaultValues); + const refreshHandler = useCallback( (force = false) => { if (!hasDataset || !formFilter?.dataset?.value) { @@ -450,6 +469,9 @@ const FiltersConfigForm = ( groupby: formFilter?.column, ...formFilter, }); + + formData.extra_form_data = dependenciesDefaultValues; + setNativeFilterFieldValuesWrapper({ defaultValueQueriesData: null, isDataDirty: false, @@ -499,14 +521,19 @@ const FiltersConfigForm = ( }); }); }, - [filterId, forceUpdate, form, formFilter, hasDataset], + [filterId, forceUpdate, form, formFilter, hasDataset, dependenciesText], ); + // TODO: refreshHandler changes itself because of the dependencies. Needs refactor. + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => refreshHandler(), [dependenciesText]); + const newFormData = getFormData({ datasetId, groupby: hasColumn ? formFilter?.column : undefined, ...formFilter, }); + newFormData.extra_form_data = dependenciesDefaultValues; const [hasDefaultValue, isRequired, defaultValueTooltip, setHasDefaultValue] = useDefaultValue(formFilter, filterToEdit); @@ -1084,6 +1111,11 @@ const FiltersConfigForm = ( tooltip={defaultValueTooltip} onChange={value => { setHasDefaultValue(value); + if (!value) { + setNativeFilterFieldValues(form, filterId, { + defaultDataMask: null, + }); + } formChanged(); }} > diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/Footer/Footer.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/Footer/Footer.tsx index 4c2f774a62758..aed85af3a2500 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/Footer/Footer.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/Footer/Footer.tsx @@ -46,7 +46,7 @@ const Footer: FC = ({ onConfirm={onConfirmCancel} onDismiss={onDismiss} > - {t(`Are you sure you want to cancel?`)} + {t('Are you sure you want to cancel?')} ); } diff --git a/superset-frontend/src/dashboard/constants.ts b/superset-frontend/src/dashboard/constants.ts index 169a2d09de192..296bc0a9b3bb3 100644 --- a/superset-frontend/src/dashboard/constants.ts +++ b/superset-frontend/src/dashboard/constants.ts @@ -35,8 +35,6 @@ export const PLACEHOLDER_DATASOURCE: Datasource = { }; export const MAIN_HEADER_HEIGHT = 53; -export const TABS_HEIGHT = 50; -export const HEADER_HEIGHT = 72; export const CLOSED_FILTER_BAR_WIDTH = 32; export const OPEN_FILTER_BAR_WIDTH = 260; export const FILTER_BAR_HEADER_HEIGHT = 80; diff --git a/superset-frontend/src/dashboard/containers/DashboardGrid.jsx b/superset-frontend/src/dashboard/containers/DashboardGrid.jsx index cbec708e54fbd..96688476112cd 100644 --- a/superset-frontend/src/dashboard/containers/DashboardGrid.jsx +++ b/superset-frontend/src/dashboard/containers/DashboardGrid.jsx @@ -24,11 +24,12 @@ import { handleComponentDrop, resizeComponent, } from '../actions/dashboardLayout'; -import { setDirectPathToChild } from '../actions/dashboardState'; +import { setDirectPathToChild, setEditMode } from '../actions/dashboardState'; -function mapStateToProps({ dashboardState }) { +function mapStateToProps({ dashboardState, dashboardInfo }) { return { editMode: dashboardState.editMode, + canEdit: dashboardInfo.dash_edit_perm, }; } @@ -38,6 +39,7 @@ function mapDispatchToProps(dispatch) { handleComponentDrop, resizeComponent, setDirectPathToChild, + setEditMode, }, dispatch, ); diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx index 97a4dc2283d65..49bf783ba3444 100644 --- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx +++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx @@ -231,7 +231,7 @@ export const DashboardPage: FC = ({ idOrSlug }: PageProps) => { }, [dashboard_title]); useEffect(() => { - if (css) { + if (typeof css === 'string') { // returning will clean up custom css // when dashboard unmounts or changes return injectCustomCss(css); diff --git a/superset-frontend/src/dashboard/stylesheets/builder.less b/superset-frontend/src/dashboard/stylesheets/builder.less index 1512e4c6fa08a..422d455622a40 100644 --- a/superset-frontend/src/dashboard/stylesheets/builder.less +++ b/superset-frontend/src/dashboard/stylesheets/builder.less @@ -22,6 +22,7 @@ flex-grow: 1; display: flex; flex-direction: column; + height: 100%; } /* only top-level tabs have popover, give it more padding to match header + tabs */ diff --git a/superset-frontend/src/dashboard/stylesheets/dashboard.less b/superset-frontend/src/dashboard/stylesheets/dashboard.less index a3409c4d48bd8..b9b2b0aab92f8 100644 --- a/superset-frontend/src/dashboard/stylesheets/dashboard.less +++ b/superset-frontend/src/dashboard/stylesheets/dashboard.less @@ -63,12 +63,20 @@ body { display: flex; max-width: 100%; align-items: flex-start; + min-height: 0; & > .header-title { overflow: hidden; text-overflow: ellipsis; max-width: 100%; flex-grow: 1; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + + & > span.ant-tooltip-open { + display: inline; + } } & > .header-controls { diff --git a/superset-frontend/src/dashboard/util/findPermission.test.ts b/superset-frontend/src/dashboard/util/findPermission.test.ts index 8930549f4a7e9..1c80770f50014 100644 --- a/superset-frontend/src/dashboard/util/findPermission.test.ts +++ b/superset-frontend/src/dashboard/util/findPermission.test.ts @@ -16,10 +16,54 @@ * specific language governing permissions and limitations * under the License. */ -import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; +import { + UndefinedUser, + UserWithPermissionsAndRoles, +} from 'src/types/bootstrapTypes'; import Dashboard from 'src/types/Dashboard'; import Owner from 'src/types/Owner'; -import findPermission, { canUserEditDashboard } from './findPermission'; +import findPermission, { + canUserEditDashboard, + isUserAdmin, +} from './findPermission'; + +const ownerUser: UserWithPermissionsAndRoles = { + createdOn: '2021-05-12T16:56:22.116839', + email: 'user@example.com', + firstName: 'Test', + isActive: true, + isAnonymous: false, + lastName: 'User', + userId: 1, + username: 'owner', + permissions: {}, + roles: { Alpha: [['can_write', 'Dashboard']] }, +}; + +const adminUser: UserWithPermissionsAndRoles = { + ...ownerUser, + roles: { + ...(ownerUser?.roles || {}), + Admin: [['can_write', 'Dashboard']], + }, + userId: 2, + username: 'admin', +}; + +const outsiderUser: UserWithPermissionsAndRoles = { + ...ownerUser, + userId: 3, + username: 'outsider', +}; + +const owner: Owner = { + first_name: 'Test', + id: ownerUser.userId, + last_name: 'User', + username: ownerUser.username, +}; + +const undefinedUser: UndefinedUser = {}; describe('findPermission', () => { it('findPermission for single role', () => { @@ -70,42 +114,6 @@ describe('findPermission', () => { }); describe('canUserEditDashboard', () => { - const ownerUser: UserWithPermissionsAndRoles = { - createdOn: '2021-05-12T16:56:22.116839', - email: 'user@example.com', - firstName: 'Test', - isActive: true, - isAnonymous: false, - lastName: 'User', - userId: 1, - username: 'owner', - permissions: {}, - roles: { Alpha: [['can_write', 'Dashboard']] }, - }; - - const adminUser: UserWithPermissionsAndRoles = { - ...ownerUser, - roles: { - ...ownerUser.roles, - Admin: [['can_write', 'Dashboard']], - }, - userId: 2, - username: 'admin', - }; - - const outsiderUser: UserWithPermissionsAndRoles = { - ...ownerUser, - userId: 3, - username: 'outsider', - }; - - const owner: Owner = { - first_name: 'Test', - id: ownerUser.userId, - last_name: 'User', - username: ownerUser.username, - }; - const dashboard: Dashboard = { id: 1, dashboard_title: 'Test Dash', @@ -136,9 +144,7 @@ describe('canUserEditDashboard', () => { it('rejects missing roles', () => { // in redux, when there is no user, the user is actually set to an empty object, // so we need to handle missing roles as well as a missing user.s - expect( - canUserEditDashboard(dashboard, {} as UserWithPermissionsAndRoles), - ).toEqual(false); + expect(canUserEditDashboard(dashboard, {})).toEqual(false); }); it('rejects "admins" if the admin role does not have edit rights for some reason', () => { expect( @@ -149,3 +155,15 @@ describe('canUserEditDashboard', () => { ).toEqual(false); }); }); + +test('isUserAdmin returns true for admin user', () => { + expect(isUserAdmin(adminUser)).toEqual(true); +}); + +test('isUserAdmin returns false for undefined user', () => { + expect(isUserAdmin(undefinedUser)).toEqual(false); +}); + +test('isUserAdmin returns false for non-admin user', () => { + expect(isUserAdmin(ownerUser)).toEqual(false); +}); diff --git a/superset-frontend/src/dashboard/util/findPermission.ts b/superset-frontend/src/dashboard/util/findPermission.ts index 8f28a03c99337..496f993bdf80d 100644 --- a/superset-frontend/src/dashboard/util/findPermission.ts +++ b/superset-frontend/src/dashboard/util/findPermission.ts @@ -17,7 +17,11 @@ * under the License. */ import memoizeOne from 'memoize-one'; -import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; +import { + isUserWithPermissionsAndRoles, + UndefinedUser, + UserWithPermissionsAndRoles, +} from 'src/types/bootstrapTypes'; import Dashboard from 'src/types/Dashboard'; type UserRoles = Record; @@ -36,18 +40,25 @@ export default findPermission; // but is hardcoded in backend logic already, so... const ADMIN_ROLE_NAME = 'admin'; -const isUserAdmin = (user: UserWithPermissionsAndRoles) => - Object.keys(user.roles).some(role => role.toLowerCase() === ADMIN_ROLE_NAME); +export const isUserAdmin = ( + user: UserWithPermissionsAndRoles | UndefinedUser, +) => + isUserWithPermissionsAndRoles(user) && + Object.keys(user.roles || {}).some( + role => role.toLowerCase() === ADMIN_ROLE_NAME, + ); const isUserDashboardOwner = ( dashboard: Dashboard, - user: UserWithPermissionsAndRoles, -) => dashboard.owners.some(owner => owner.username === user.username); + user: UserWithPermissionsAndRoles | UndefinedUser, +) => + isUserWithPermissionsAndRoles(user) && + dashboard.owners.some(owner => owner.username === user.username); export const canUserEditDashboard = ( dashboard: Dashboard, - user?: UserWithPermissionsAndRoles | null, + user?: UserWithPermissionsAndRoles | UndefinedUser | null, ) => - !!user?.roles && + isUserWithPermissionsAndRoles(user) && (isUserAdmin(user) || isUserDashboardOwner(dashboard, user)) && findPermission('can_write', 'Dashboard', user.roles); diff --git a/superset-frontend/src/dashboard/util/getFormDataWithExtraFilters.test.ts b/superset-frontend/src/dashboard/util/getFormDataWithExtraFilters.test.ts index 0756ef03b3fcb..fda5edc1a92e4 100644 --- a/superset-frontend/src/dashboard/util/getFormDataWithExtraFilters.test.ts +++ b/superset-frontend/src/dashboard/util/getFormDataWithExtraFilters.test.ts @@ -16,13 +16,9 @@ * specific language governing permissions and limitations * under the License. */ -import { Filter } from '@superset-ui/core'; import getFormDataWithExtraFilters, { GetFormDataWithExtraFiltersArguments, } from 'src/dashboard/util/charts/getFormDataWithExtraFilters'; -import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants'; -import { LayoutItem } from 'src/dashboard/types'; -import { dashboardLayout } from 'spec/fixtures/mockDashboardLayout'; import { sliceId as chartId } from 'spec/fixtures/mockChartQueries'; describe('getFormDataWithExtraFilters', () => { @@ -63,16 +59,8 @@ describe('getFormDataWithExtraFilters', () => { }, sliceId: chartId, nativeFilters: { + filters: {}, filterSets: {}, - filters: { - [filterId]: { - id: filterId, - scope: { - rootPath: [DASHBOARD_ROOT_ID], - excluded: [], - }, - } as unknown as Filter, - }, }, dataMask: { [filterId]: { @@ -82,9 +70,7 @@ describe('getFormDataWithExtraFilters', () => { ownState: {}, }, }, - layout: dashboardLayout.present as unknown as { - [key: string]: LayoutItem; - }, + layout: {}, }; it('should include filters from the passed filters', () => { diff --git a/superset-frontend/src/dashboard/util/injectCustomCss.ts b/superset-frontend/src/dashboard/util/injectCustomCss.ts index 36b3f4d7621e0..43cb66f7d91a0 100644 --- a/superset-frontend/src/dashboard/util/injectCustomCss.ts +++ b/superset-frontend/src/dashboard/util/injectCustomCss.ts @@ -40,7 +40,7 @@ export default function injectCustomCss(css: string) { document.querySelector(`.${className}`) || createStyleElement(className); if ('styleSheet' in style) { - (style as unknown as MysteryStyleElement).styleSheet.cssText = css; + (style as HTMLStyleElement & MysteryStyleElement).styleSheet.cssText = css; } else { style.innerHTML = css; } diff --git a/superset-frontend/src/explore/components/ChartPills.tsx b/superset-frontend/src/explore/components/ChartPills.tsx new file mode 100644 index 0000000000000..09595576dd753 --- /dev/null +++ b/superset-frontend/src/explore/components/ChartPills.tsx @@ -0,0 +1,88 @@ +/** + * 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 React, { forwardRef, RefObject } from 'react'; +import { css, QueryData, SupersetTheme } from '@superset-ui/core'; +import RowCountLabel from 'src/explore/components/RowCountLabel'; +import CachedLabel from 'src/components/CachedLabel'; +import Timer from 'src/components/Timer'; + +enum CHART_STATUS_MAP { + failed = 'danger', + loading = 'warning', + success = 'success', +} + +export type ChartPillsProps = { + queriesResponse: QueryData[]; + chartStatus: keyof typeof CHART_STATUS_MAP; + chartUpdateStartTime: number; + chartUpdateEndTime: number; + refreshCachedQuery: () => void; + rowLimit: string | number; +}; + +export const ChartPills = forwardRef( + ( + { + queriesResponse, + chartStatus, + chartUpdateStartTime, + chartUpdateEndTime, + refreshCachedQuery, + rowLimit, + }: ChartPillsProps, + ref: RefObject, + ) => { + const isLoading = chartStatus === 'loading'; + const firstQueryResponse = queriesResponse?.[0]; + return ( +
+
css` + display: flex; + justify-content: flex-end; + padding-bottom: ${theme.gridUnit * 4}px; + & .ant-tag:last-of-type { + margin: 0; + } + `} + > + {!isLoading && firstQueryResponse && ( + + )} + {!isLoading && firstQueryResponse?.is_cached && ( + + )} + +
+
+ ); + }, +); diff --git a/superset-frontend/src/explore/components/Control.less b/superset-frontend/src/explore/components/Control.less deleted file mode 100644 index 87b223c285462..0000000000000 --- a/superset-frontend/src/explore/components/Control.less +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -.Control { - padding-bottom: 4px; -} diff --git a/superset-frontend/src/explore/components/Control.test.tsx b/superset-frontend/src/explore/components/Control.test.tsx new file mode 100644 index 0000000000000..3921d43746636 --- /dev/null +++ b/superset-frontend/src/explore/components/Control.test.tsx @@ -0,0 +1,94 @@ +/** + * 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 { mount } from 'enzyme'; +import { + ThemeProvider, + supersetTheme, + promiseTimeout, +} from '@superset-ui/core'; +import React from 'react'; +import { render, screen } from 'spec/helpers/testing-library'; +import Control, { ControlProps } from 'src/explore/components/Control'; + +const defaultProps: ControlProps = { + type: 'CheckboxControl', + name: 'checkbox', + value: true, + actions: { + setControlValue: jest.fn(), + }, +}; + +const setup = (overrides = {}) => ( + + + +); + +describe('Control', () => { + it('render a control', () => { + render(setup()); + + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toBeVisible(); + }); + + it('render null if type is not exit', () => { + render( + setup({ + type: undefined, + }), + ); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + }); + + it('render null if type is not valid', () => { + render( + setup({ + type: 'UnkownControl', + }), + ); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + }); + + it('render null if isVisible is false', () => { + render( + setup({ + isVisible: false, + }), + ); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + }); + + it('call setControlValue if isVisible is false', () => { + const wrapper = mount( + setup({ + isVisible: true, + default: false, + }), + ); + wrapper.setProps({ + isVisible: false, + default: false, + }); + promiseTimeout(() => { + expect(defaultProps.actions.setControlValue).toBeCalled(); + }, 100); + }); +}); diff --git a/superset-frontend/src/explore/components/Control.tsx b/superset-frontend/src/explore/components/Control.tsx index 0f4820f4a2b5c..5e202fdf10dad 100644 --- a/superset-frontend/src/explore/components/Control.tsx +++ b/superset-frontend/src/explore/components/Control.tsx @@ -16,18 +16,18 @@ * specific language governing permissions and limitations * under the License. */ -import React, { ReactNode, useCallback, useState } from 'react'; +import React, { ReactNode, useCallback, useState, useEffect } from 'react'; +import { isEqual } from 'lodash'; import { ControlType, ControlComponentProps as BaseControlComponentProps, } from '@superset-ui/chart-controls'; -import { JsonValue, QueryFormData } from '@superset-ui/core'; +import { styled, JsonValue, QueryFormData } from '@superset-ui/core'; +import { usePrevious } from 'src/hooks/usePrevious'; import ErrorBoundary from 'src/components/ErrorBoundary'; import { ExploreActions } from 'src/explore/actions/exploreActions'; import controlMap from './controls'; -import './Control.less'; - export type ControlProps = { // the actual action dispatcher (via bindActionCreators) has identical // signature to the original action factory. @@ -44,6 +44,9 @@ export type ControlProps = { validationErrors?: any[]; hidden?: boolean; renderTrigger?: boolean; + default?: JsonValue; + isVisible?: boolean; + resetOnHide?: boolean; }; /** @@ -52,21 +55,48 @@ export type ControlProps = { export type ControlComponentProps = Omit & BaseControlComponentProps; +const StyledControl = styled.div` + padding-bottom: ${({ theme }) => theme.gridUnit * 4}px; +`; + export default function Control(props: ControlProps) { const { actions: { setControlValue }, name, type, hidden, + isVisible, + resetOnHide = true, } = props; const [hovered, setHovered] = useState(false); + const wasVisible = usePrevious(isVisible); const onChange = useCallback( (value: any, errors: any[]) => setControlValue(name, value, errors), [name, setControlValue], ); - if (!type) return null; + useEffect(() => { + if ( + wasVisible === true && + isVisible === false && + props.default !== undefined && + !isEqual(props.value, props.default) && + resetOnHide + ) { + // reset control value if setting to invisible + setControlValue?.(name, props.default); + } + }, [ + name, + wasVisible, + isVisible, + setControlValue, + props.value, + props.default, + ]); + + if (!type || isVisible === false) return null; const ControlComponent = typeof type === 'string' ? controlMap[type] : type; if (!ControlComponent) { @@ -76,7 +106,7 @@ export default function Control(props: ControlProps) { } return ( -
-
+ ); } diff --git a/superset-frontend/src/explore/components/ControlHeader.tsx b/superset-frontend/src/explore/components/ControlHeader.tsx index ce240704b5d3f..16bf93d047c4a 100644 --- a/superset-frontend/src/explore/components/ControlHeader.tsx +++ b/superset-frontend/src/explore/components/ControlHeader.tsx @@ -17,7 +17,7 @@ * under the License. */ import React, { FC, ReactNode } from 'react'; -import { t, css, useTheme } from '@superset-ui/core'; +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'; @@ -106,10 +106,12 @@ const ControlHeader: FC = ({
+ css` + margin-bottom: ${theme.gridUnit * 0.5}px; + position: relative; + ` + } > {leftNode && {leftNode}} void; - secondaryButtonAction?: (e: React.MouseEvent) => void; - primaryButtonText: string; - secondaryButtonText?: string; - type: 'info' | 'warning'; -} - -const AlertContainer = styled.div` - margin: ${({ theme }) => theme.gridUnit * 4}px; - padding: ${({ theme }) => theme.gridUnit * 4}px; - - border: ${({ theme }) => `1px solid ${theme.colors.info.base}`}; - background-color: ${({ theme }) => theme.colors.info.light2}; - border-radius: 2px; - - color: ${({ theme }) => theme.colors.info.dark2}; - font-size: ${({ theme }) => theme.typography.sizes.s}; - - &.alert-type-warning { - border-color: ${({ theme }) => theme.colors.alert.base}; - background-color: ${({ theme }) => theme.colors.alert.light2}; - - p { - color: ${({ theme }) => theme.colors.alert.dark2}; - } - } -`; - -const ButtonContainer = styled.div` - display: flex; - justify-content: flex-end; - button { - line-height: 1; - } -`; - -const Title = styled.p` - font-weight: ${({ theme }) => theme.typography.weights.bold}; -`; - -export const ControlPanelAlert = ({ - title, - bodyText, - primaryButtonAction, - secondaryButtonAction, - primaryButtonText, - secondaryButtonText, - type = 'info', -}: ControlPanelAlertProps) => ( - - {title} -

{bodyText}

- - {secondaryButtonAction && secondaryButtonText && ( - - )} - - -
-); diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx index 24101d9c17b9c..6af860470cce4 100644 --- a/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx +++ b/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx @@ -30,7 +30,7 @@ import { ControlPanelsContainerProps, } from 'src/explore/components/ControlPanelsContainer'; -describe('ControlPanelsContainer2', () => { +describe('ControlPanelsContainer', () => { beforeAll(() => { getChartControlPanelRegistry().registerValue('table', { controlPanelSections: [ @@ -90,6 +90,10 @@ describe('ControlPanelsContainer2', () => { form_data: getFormDataFromControls(controls), isDatasourceMetaLoading: false, exploreState: {}, + chart: { + queriesResponse: null, + chartStatus: 'success', + }, } as ControlPanelsContainerProps; } diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx index 650e5f00c0c11..98d80275f6977 100644 --- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx +++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx @@ -18,6 +18,7 @@ */ /* eslint camelcase: 0 */ import React, { + ReactNode, useCallback, useContext, useEffect, @@ -33,6 +34,7 @@ import { QueryFormData, DatasourceType, css, + SupersetTheme, } from '@superset-ui/core'; import { ControlPanelSectionConfig, @@ -54,10 +56,12 @@ import { getSectionsToRender } from 'src/explore/controlUtils'; import { ExploreActions } from 'src/explore/actions/exploreActions'; import { ExplorePageState } from 'src/explore/reducers/getInitialState'; import { ChartState } from 'src/explore/types'; +import { Tooltip } from 'src/components/Tooltip'; import ControlRow from './ControlRow'; import Control from './Control'; -import { ControlPanelAlert } from './ControlPanelAlert'; +import { ExploreAlert } from './ExploreAlert'; +import { RunQueryButton } from './RunQueryButton'; export type ControlPanelsContainerProps = { exploreState: ExplorePageState['explore']; @@ -67,6 +71,11 @@ export type ControlPanelsContainerProps = { controls: Record; form_data: QueryFormData; isDatasourceMetaLoading: boolean; + errorMessage: ReactNode; + onQuery: () => void; + onStop: () => void; + canStopQuery: boolean; + chartIsStale: boolean; }; export type ExpandedControlPanelSectionConfig = Omit< @@ -76,13 +85,34 @@ export type ExpandedControlPanelSectionConfig = Omit< controlSetRows: ExpandedControlItem[][]; }; +const actionButtonsContainerStyles = (theme: SupersetTheme) => css` + display: flex; + position: sticky; + bottom: 0; + flex-direction: column; + align-items: center; + padding: ${theme.gridUnit * 4}px; + z-index: 999; + background: linear-gradient( + transparent, + ${theme.colors.grayscale.light5} ${theme.opacity.mediumLight} + ); + + & > button { + min-width: 156px; + } +`; + const Styles = styled.div` + position: relative; height: 100%; width: 100%; - overflow: auto; - overflow-x: visible; + + // Resizable add overflow-y: auto as a style to this div + // To override it, we need to use !important + overflow: visible !important; #controlSections { - min-height: 100%; + height: 100%; overflow: visible; } .nav-tabs { @@ -105,15 +135,37 @@ const Styles = styled.div` `; const ControlPanelsTabs = styled(Tabs)` - .ant-tabs-nav-list { - width: ${({ fullWidth }) => (fullWidth ? '100%' : '50%')}; - } - .ant-tabs-content-holder { - overflow: visible; - } - .ant-tabs-tabpane { + ${({ theme, fullWidth }) => css` height: 100%; - } + overflow: visible; + .ant-tabs-nav { + margin-bottom: 0; + } + .ant-tabs-nav-list { + width: ${fullWidth ? '100%' : '50%'}; + } + .ant-tabs-tabpane { + height: 100%; + } + .ant-tabs-content-holder { + padding-top: ${theme.gridUnit * 4}px; + } + + .ant-collapse-ghost > .ant-collapse-item { + &:not(:last-child) { + border-bottom: 1px solid ${theme.colors.grayscale.light3}; + } + + & > .ant-collapse-header { + font-size: ${theme.typography.sizes.s}px; + } + + & > .ant-collapse-content > .ant-collapse-content-box { + padding-bottom: 0; + font-size: ${theme.typography.sizes.s}px; + } + } + `} `; const isTimeSection = (section: ControlPanelSectionConfig): boolean => @@ -283,16 +335,17 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { validationErrors?: any[]; }; - // if visibility check says the config is not visible, don't render it - if (visibility && !visibility.call(config, props, controlData)) { - return null; - } + const isVisible = visibility + ? visibility.call(config, props, controlData) + : undefined; + return ( ); @@ -349,7 +402,8 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { box-shadow: none; &:last-child { - padding-bottom: ${theme.gridUnit * 10}px; + padding-bottom: ${theme.gridUnit * 16}px; + border-bottom: 0; } .panel-body { @@ -406,7 +460,7 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { const DatasourceAlert = useCallback( () => hasControlsTransferred ? ( - { type="info" /> ) : ( - { [handleClearFormClick, handleContinueClick, hasControlsTransferred], ); + const dataTabTitle = useMemo( + () => ( + <> + {t('Data')} + {props.errorMessage && ( + css` + font-size: ${theme.typography.sizes.xs}px; + margin-left: ${theme.gridUnit * 2}px; + `} + > + {' '} + + + + + )} + + ), + [props.errorMessage], + ); + const controlPanelRegistry = getChartControlPanelRegistry(); if ( !controlPanelRegistry.has(props.form_data.viz_type) && @@ -447,10 +527,10 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { id="controlSections" data-test="control-tabs" fullWidth={showCustomizeTab} + allowOverflow={false} > - + { {showCustomizeTab && ( { )} +
+ +
); }; diff --git a/superset-frontend/src/explore/components/ControlRow.test.tsx b/superset-frontend/src/explore/components/ControlRow.test.tsx index 638b6772d7e44..0b57078676548 100644 --- a/superset-frontend/src/explore/components/ControlRow.test.tsx +++ b/superset-frontend/src/explore/components/ControlRow.test.tsx @@ -17,20 +17,51 @@ * under the License. */ import React from 'react'; -import { render } from 'spec/helpers/testing-library'; +import { render, screen } from 'spec/helpers/testing-library'; import ControlSetRow from 'src/explore/components/ControlRow'; +const MockControl = (props: { + children: React.ReactElement; + type?: string; + isVisible?: boolean; +}) =>
{props.children}
; describe('ControlSetRow', () => { it('renders a single row with one element', () => { - const { getAllByText } = render( - My Control 1

]} />, - ); - expect(getAllByText('My Control 1').length).toBe(1); + render(My Control 1

]} />); + expect(screen.getAllByText('My Control 1').length).toBe(1); }); it('renders a single row with two elements', () => { - const { getAllByText } = render( + render( My Control 1

,

My Control 2

]} />, ); - expect(getAllByText(/My Control/)).toHaveLength(2); + expect(screen.getAllByText(/My Control/)).toHaveLength(2); + }); + + it('renders a single row with one elements if is HiddenControl', () => { + render( + My Control 1

, + +

My Control 2

+
, + ]} + />, + ); + expect(screen.getAllByText(/My Control/)).toHaveLength(2); + }); + + it('renders a single row with one elements if is invisible', () => { + render( + My Control 1

, + +

My Control 2

+
, + ]} + />, + ); + expect(screen.getAllByText(/My Control/)).toHaveLength(2); }); }); diff --git a/superset-frontend/src/explore/components/ControlRow.tsx b/superset-frontend/src/explore/components/ControlRow.tsx index 4a1dfd5789842..5721b5de28937 100644 --- a/superset-frontend/src/explore/components/ControlRow.tsx +++ b/superset-frontend/src/explore/components/ControlRow.tsx @@ -16,26 +16,34 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; +import React, { useCallback } from 'react'; const NUM_COLUMNS = 12; type Control = React.ReactElement | null; export default function ControlRow({ controls }: { controls: Control[] }) { - // ColorMapControl renders null and should not be counted + const isHiddenControl = useCallback( + (control: Control) => + control?.props.type === 'HiddenControl' || + control?.props.isVisible === false, + [], + ); + // Invisible control should not be counted // in the columns number const countableControls = controls.filter( - control => !['ColorMapControl'].includes(control?.props.type), + control => !isHiddenControl(control), ); - const colSize = NUM_COLUMNS / countableControls.length; + const colSize = countableControls.length + ? NUM_COLUMNS / countableControls.length + : NUM_COLUMNS; return (
{controls.map((control, i) => (
diff --git a/superset-frontend/src/explore/components/DataTableControl/index.tsx b/superset-frontend/src/explore/components/DataTableControl/index.tsx index c94c07cb74fd9..7a25c374bd8ac 100644 --- a/superset-frontend/src/explore/components/DataTableControl/index.tsx +++ b/superset-frontend/src/explore/components/DataTableControl/index.tsx @@ -67,41 +67,56 @@ export const CopyButton = styled(Button)` } `; -const CopyNode = ( - - - -); - export const CopyToClipboardButton = ({ data, columns, }: { data?: Record; columns?: string[]; -}) => ( - -); +}) => { + const theme = useTheme(); + return ( + * { + line-height: 0; + } + `} + /> + } + /> + ); +}; export const FilterInput = ({ onChangeHandler, }: { onChangeHandler(filterText: string): void; }) => { + const theme = useTheme(); const debouncedChangeHandler = debounce(onChangeHandler, SLOW_DEBOUNCE); return ( } placeholder={t('Search')} onChange={(event: any) => { const filterText = event.target.value; debouncedChangeHandler(filterText); }} + css={css` + width: 200px; + margin-right: ${theme.gridUnit * 2}px; + `} /> ); }; @@ -250,7 +265,9 @@ export const useFilteredTableData = ( const rowsAsStrings = useMemo( () => data?.map((row: Record) => - Object.values(row).map(value => value?.toString().toLowerCase()), + Object.values(row).map(value => + value ? value.toString().toLowerCase() : t('N/A'), + ), ) ?? [], [data], ); diff --git a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx index 9905d8f5c6d3c..786150449ee20 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx @@ -21,7 +21,11 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; import fetchMock from 'fetch-mock'; import * as copyUtils from 'src/utils/copy'; -import { render, screen } from 'spec/helpers/testing-library'; +import { + render, + screen, + waitForElementToBeRemoved, +} from 'spec/helpers/testing-library'; import { DataTablesPane } from '.'; const createProps = () => ({ @@ -50,7 +54,6 @@ const createProps = () => ({ sort_y_axis: 'alpha_asc', extra_form_data: {}, }, - tableSectionHeight: 156.9, chartStatus: 'rendered', onCollapseChange: jest.fn(), queriesResponse: [ @@ -60,91 +63,162 @@ const createProps = () => ({ ], }); -test('Rendering DataTablesPane correctly', () => { - const props = createProps(); - render(, { useRedux: true }); - expect(screen.getByTestId('some-purposeful-instance')).toBeVisible(); - expect(screen.getByRole('tablist')).toBeVisible(); - expect(screen.getByRole('tab', { name: 'right Data' })).toBeVisible(); - expect(screen.getByRole('img', { name: 'right' })).toBeVisible(); -}); +describe('DataTablesPane', () => { + // Collapsed/expanded state depends on local storage + // We need to clear it manually - otherwise initial state would depend on the order of tests + beforeEach(() => { + localStorage.clear(); + }); -test('Should show tabs', async () => { - const props = createProps(); - render(, { useRedux: true }); - expect(screen.queryByText('View results')).not.toBeInTheDocument(); - expect(screen.queryByText('View samples')).not.toBeInTheDocument(); - userEvent.click(await screen.findByText('Data')); - expect(await screen.findByText('View results')).toBeVisible(); - expect(screen.getByText('View samples')).toBeVisible(); -}); + afterAll(() => { + localStorage.clear(); + }); -test('Should show tabs: View results', async () => { - const props = createProps(); - render(, { - useRedux: true, + test('Rendering DataTablesPane correctly', () => { + const props = createProps(); + render(, { useRedux: true }); + expect(screen.getByText('Results')).toBeVisible(); + expect(screen.getByText('Samples')).toBeVisible(); + expect(screen.getByLabelText('Expand data panel')).toBeVisible(); }); - userEvent.click(await screen.findByText('Data')); - userEvent.click(await screen.findByText('View results')); - expect(screen.getByText('0 rows retrieved')).toBeVisible(); -}); -test('Should show tabs: View samples', async () => { - const props = createProps(); - render(, { - useRedux: true, + test('Collapse/Expand buttons', async () => { + const props = createProps(); + render(, { + useRedux: true, + }); + expect( + screen.queryByLabelText('Collapse data panel'), + ).not.toBeInTheDocument(); + userEvent.click(screen.getByLabelText('Expand data panel')); + expect(await screen.findByLabelText('Collapse data panel')).toBeVisible(); + expect( + screen.queryByLabelText('Expand data panel'), + ).not.toBeInTheDocument(); }); - userEvent.click(await screen.findByText('Data')); - expect(screen.queryByText('0 rows retrieved')).not.toBeInTheDocument(); - userEvent.click(await screen.findByText('View samples')); - expect(await screen.findByText('0 rows retrieved')).toBeVisible(); -}); -test('Should copy data table content correctly', async () => { - fetchMock.post( - 'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D', - { - result: [ - { - data: [{ __timestamp: 1230768000000, genre: 'Action' }], - colnames: ['__timestamp', 'genre'], - coltypes: [2, 1], + test('Should show tabs: View results', async () => { + const props = createProps(); + render(, { + useRedux: true, + }); + userEvent.click(screen.getByText('Results')); + expect(await screen.findByText('0 rows retrieved')).toBeVisible(); + expect(await screen.findByLabelText('Collapse data panel')).toBeVisible(); + localStorage.clear(); + }); + + test('Should show tabs: View samples', async () => { + const props = createProps(); + render(, { + useRedux: true, + }); + userEvent.click(screen.getByText('Samples')); + expect(await screen.findByText('0 rows retrieved')).toBeVisible(); + expect(await screen.findByLabelText('Collapse data panel')).toBeVisible(); + }); + + test('Should copy data table content correctly', async () => { + fetchMock.post( + 'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D', + { + result: [ + { + data: [{ __timestamp: 1230768000000, genre: 'Action' }], + colnames: ['__timestamp', 'genre'], + coltypes: [2, 1], + }, + ], + }, + ); + const copyToClipboardSpy = jest.spyOn(copyUtils, 'default'); + const props = createProps(); + render( + , + { + useRedux: true, + initialState: { + explore: { + timeFormattedColumns: { + '34__table': ['__timestamp'], + }, + }, }, - ], - }, - ); - const copyToClipboardSpy = jest.spyOn(copyUtils, 'default'); - const props = createProps(); - render( - { + fetchMock.post( + 'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D', + { + result: [ { + data: [ + { __timestamp: 1230768000000, genre: 'Action' }, + { __timestamp: 1230768000010, genre: 'Horror' }, + ], colnames: ['__timestamp', 'genre'], coltypes: [2, 1], }, ], - }} - />, - { - useRedux: true, - initialState: { - explore: { - timeFormattedColumns: { - '34__table': ['__timestamp'], + }, + ); + const props = createProps(); + render( + , + { + useRedux: true, + initialState: { + explore: { + timeFormattedColumns: { + '34__table': ['__timestamp'], + }, }, }, }, - }, - ); - userEvent.click(await screen.findByText('Data')); - expect(await screen.findByText('1 rows retrieved')).toBeVisible(); + ); + userEvent.click(screen.getByText('Results')); + expect(await screen.findByText('2 rows retrieved')).toBeVisible(); + expect(screen.getByText('Action')).toBeVisible(); + expect(screen.getByText('Horror')).toBeVisible(); - userEvent.click(screen.getByRole('button', { name: 'Copy' })); - expect(copyToClipboardSpy).toHaveBeenCalledWith( - '2009-01-01 00:00:00\tAction\n', - ); - fetchMock.done(); + userEvent.type(screen.getByPlaceholderText('Search'), 'hor'); + + await waitForElementToBeRemoved(() => screen.queryByText('Action')); + expect(screen.getByText('Horror')).toBeVisible(); + expect(screen.queryByText('Action')).not.toBeInTheDocument(); + fetchMock.restore(); + }); }); diff --git a/superset-frontend/src/explore/components/DataTablesPane/index.tsx b/superset-frontend/src/explore/components/DataTablesPane/index.tsx index 5d935caa63ddd..a41af3626f1e4 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/index.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/index.tsx @@ -16,15 +16,23 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useState, + MouseEvent, +} from 'react'; import { + css, ensureIsArray, GenericDataType, JsonObject, styled, t, + useTheme, } from '@superset-ui/core'; -import Collapse from 'src/components/Collapse'; +import Icons from 'src/components/Icons'; import Tabs from 'src/components/Tabs'; import Loading from 'src/components/Loading'; import { EmptyStateMedium } from 'src/components/EmptyState'; @@ -58,53 +66,58 @@ const getDefaultDataTablesState = (value: any) => ({ const DATA_TABLE_PAGE_SIZE = 50; -const DATAPANEL_KEY = 'data'; - const TableControlsWrapper = styled.div` - display: flex; - align-items: center; - - span { - flex-shrink: 0; - } + ${({ theme }) => ` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: ${theme.gridUnit * 2}px; + + span { + flex-shrink: 0; + } + `} `; const SouthPane = styled.div` - position: relative; - background-color: ${({ theme }) => theme.colors.grayscale.light5}; - z-index: 5; - overflow: hidden; -`; - -const TabsWrapper = styled.div<{ contentHeight: number }>` - height: ${({ contentHeight }) => contentHeight}px; - overflow: hidden; + ${({ theme }) => ` + position: relative; + background-color: ${theme.colors.grayscale.light5}; + z-index: 5; + overflow: hidden; - .table-condensed { - height: 100%; - overflow: auto; - } -`; + .ant-tabs { + height: 100%; + } -const CollapseWrapper = styled.div` - height: 100%; + .ant-tabs-content-holder { + height: 100%; + } - .collapse-inner { - height: 100%; + .ant-tabs-content { + height: 100%; + } - .ant-collapse-item { + .ant-tabs-tabpane { + display: flex; + flex-direction: column; height: 100%; - .ant-collapse-content { - height: calc(100% - ${({ theme }) => theme.gridUnit * 8}px); + .table-condensed { + height: 100%; + overflow: auto; + margin-bottom: ${theme.gridUnit * 4}px; - .ant-collapse-content-box { - padding-top: 0; - height: 100%; + .table { + margin-bottom: ${theme.gridUnit * 2}px; } } + + .pagination-container > ul[role='navigation'] { + margin-top: 0; + } } - } + `} `; const Error = styled.pre` @@ -117,7 +130,6 @@ interface DataTableProps { datasource: string | undefined; filterText: string; data: object[] | undefined; - timeFormattedColumns: string[] | undefined; isLoading: boolean; error: string | undefined; errorMessage: React.ReactElement | undefined; @@ -130,12 +142,12 @@ const DataTable = ({ datasource, filterText, data, - timeFormattedColumns, isLoading, error, errorMessage, type, }: DataTableProps) => { + const timeFormattedColumns = useTimeFormattedColumns(datasource); // this is to preserve the order of the columns, even if there are integer values, // while also only grabbing the first column's keys const columns = useTableColumns( @@ -185,9 +197,42 @@ const DataTable = ({ return null; }; +const TableControls = ({ + data, + datasourceId, + onInputChange, + columnNames, + isLoading, +}: { + data: Record[]; + datasourceId?: string; + onInputChange: (input: string) => void; + columnNames: string[]; + isLoading: boolean; +}) => { + const timeFormattedColumns = useTimeFormattedColumns(datasourceId); + const formattedData = useMemo( + () => applyFormattingToTabularData(data, timeFormattedColumns), + [data, timeFormattedColumns], + ); + return ( + + +
+ + +
+
+ ); +}; + export const DataTablesPane = ({ queryFormData, - tableSectionHeight, onCollapseChange, chartStatus, ownState, @@ -195,19 +240,19 @@ export const DataTablesPane = ({ queriesResponse, }: { queryFormData: Record; - tableSectionHeight: number; chartStatus: string; ownState?: JsonObject; - onCollapseChange: (openPanelName: string) => void; + onCollapseChange: (isOpen: boolean) => void; errorMessage?: JSX.Element; queriesResponse: Record; }) => { + const theme = useTheme(); const [data, setData] = useState(getDefaultDataTablesState(undefined)); const [isLoading, setIsLoading] = useState(getDefaultDataTablesState(true)); const [columnNames, setColumnNames] = useState(getDefaultDataTablesState([])); const [columnTypes, setColumnTypes] = useState(getDefaultDataTablesState([])); const [error, setError] = useState(getDefaultDataTablesState('')); - const [filterText, setFilterText] = useState(''); + const [filterText, setFilterText] = useState(getDefaultDataTablesState('')); const [activeTabKey, setActiveTabKey] = useState( RESULT_TYPES.results, ); @@ -218,24 +263,6 @@ export const DataTablesPane = ({ getItem(LocalStorageKeys.is_datapanel_open, false), ); - const timeFormattedColumns = useTimeFormattedColumns( - queryFormData?.datasource, - ); - - const formattedData = useMemo( - () => ({ - [RESULT_TYPES.results]: applyFormattingToTabularData( - data[RESULT_TYPES.results], - timeFormattedColumns, - ), - [RESULT_TYPES.samples]: applyFormattingToTabularData( - data[RESULT_TYPES.samples], - timeFormattedColumns, - ), - }), - [data, timeFormattedColumns], - ); - const getData = useCallback( (resultType: 'samples' | 'results') => { setIsLoading(prevIsLoading => ({ @@ -381,81 +408,121 @@ export const DataTablesPane = ({ errorMessage, ]); - const TableControls = ( - - - - - + const handleCollapseChange = useCallback( + (isOpen: boolean) => { + onCollapseChange(isOpen); + setPanelOpen(isOpen); + }, + [onCollapseChange], ); - const handleCollapseChange = (openPanelName: string) => { - onCollapseChange(openPanelName); - setPanelOpen(!!openPanelName); - }; + const handleTabClick = useCallback( + (tabKey: string, e: MouseEvent) => { + if (!panelOpen) { + handleCollapseChange(true); + } else if (tabKey === activeTabKey) { + e.preventDefault(); + handleCollapseChange(false); + } + setActiveTabKey(tabKey); + }, + [activeTabKey, handleCollapseChange, panelOpen], + ); + + const CollapseButton = useMemo(() => { + const caretIcon = panelOpen ? ( + + ) : ( + + ); + return ( + + {panelOpen ? ( + handleCollapseChange(false)} + > + {caretIcon} + + ) : ( + handleCollapseChange(true)} + > + {caretIcon} + + )} + + ); + }, [handleCollapseChange, panelOpen, theme.colors.grayscale.base]); return ( - - - - - - - - - - - - - - - - + + + + setFilterText(prevState => ({ + ...prevState, + [RESULT_TYPES.results]: input, + })) + } + isLoading={isLoading[RESULT_TYPES.results]} + /> + + + + + setFilterText(prevState => ({ + ...prevState, + [RESULT_TYPES.samples]: input, + })) + } + isLoading={isLoading[RESULT_TYPES.samples]} + /> + + + ); }; diff --git a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx index ebed661be9e46..c38c1b59ae4fc 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx @@ -86,7 +86,7 @@ const DatasourceContainer = styled.div` color: ${theme.colors.grayscale.light1}; } .form-control.input-md { - width: calc(100% - ${theme.gridUnit * 4}px); + width: calc(100% - ${theme.gridUnit * 8}px); height: ${theme.gridUnit * 8}px; margin: ${theme.gridUnit * 2}px auto; } diff --git a/superset-frontend/src/explore/components/ExploreAdditionalActionsMenu/index.jsx b/superset-frontend/src/explore/components/ExploreAdditionalActionsMenu/index.jsx index 640912d694c5f..fa9b54acf5025 100644 --- a/superset-frontend/src/explore/components/ExploreAdditionalActionsMenu/index.jsx +++ b/superset-frontend/src/explore/components/ExploreAdditionalActionsMenu/index.jsx @@ -86,8 +86,8 @@ const MenuItemWithCheckboxContainer = styled.div` const MenuTrigger = styled(Button)` ${({ theme }) => css` - width: ${theme.gridUnit * 6}px; - height: ${theme.gridUnit * 6}px; + width: ${theme.gridUnit * 8}px; + height: ${theme.gridUnit * 8}px; padding: 0; border: 1px solid ${theme.colors.primary.dark2}; @@ -370,31 +370,35 @@ const ExploreAdditionalActionsMenu = ({ - {canAddReports && - (report ? ( - - - - - {t('Email reports active')} - - - - {t('Edit email report')} - - - {t('Delete email report')} + {canAddReports && ( + <> + {report ? ( + + + + + {t('Email reports active')} + + + + {t('Edit email report')} + + + {t('Delete email report')} + + + ) : ( + + {t('Set up an email report')} - - ) : ( - - {t('Set up an email report')} - - ))} - + )} + + + )} + diff --git a/superset-frontend/src/explore/components/ExploreAlert.tsx b/superset-frontend/src/explore/components/ExploreAlert.tsx new file mode 100644 index 0000000000000..34c4cf070e30a --- /dev/null +++ b/superset-frontend/src/explore/components/ExploreAlert.tsx @@ -0,0 +1,127 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { forwardRef, RefObject } from 'react'; +import { css, styled } from '@superset-ui/core'; +import Button from 'src/components/Button'; + +interface ControlPanelAlertProps { + title: string; + bodyText: string; + primaryButtonAction?: (e: React.MouseEvent) => void; + secondaryButtonAction?: (e: React.MouseEvent) => void; + primaryButtonText?: string; + secondaryButtonText?: string; + type: 'info' | 'warning'; + className?: string; +} + +const AlertContainer = styled.div` + ${({ theme }) => css` + margin: ${theme.gridUnit * 4}px; + padding: ${theme.gridUnit * 4}px; + + border: 1px solid ${theme.colors.info.base}; + background-color: ${theme.colors.info.light2}; + border-radius: 2px; + + color: ${theme.colors.info.dark2}; + font-size: ${theme.typography.sizes.m}px; + + p { + margin-bottom: ${theme.gridUnit}px; + } + + & a, + & span[role='button'] { + color: inherit; + text-decoration: underline; + &:hover { + color: ${theme.colors.info.dark1}; + } + } + + &.alert-type-warning { + border-color: ${theme.colors.alert.base}; + background-color: ${theme.colors.alert.light2}; + + p { + color: ${theme.colors.alert.dark2}; + } + + & a:hover, + & span[role='button']:hover { + color: ${theme.colors.alert.dark1}; + } + } + `} +`; + +const ButtonContainer = styled.div` + display: flex; + justify-content: flex-end; + button { + line-height: 1; + } +`; + +const Title = styled.p` + font-weight: ${({ theme }) => theme.typography.weights.bold}; +`; + +export const ExploreAlert = forwardRef( + ( + { + title, + bodyText, + primaryButtonAction, + secondaryButtonAction, + primaryButtonText, + secondaryButtonText, + type = 'info', + className = '', + }: ControlPanelAlertProps, + ref: RefObject, + ) => ( + + {title} +

{bodyText}

+ {primaryButtonText && primaryButtonAction && ( + + {secondaryButtonAction && secondaryButtonText && ( + + )} + + + )} +
+ ), +); diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/ChartEditableTitle/ChartEditableTitle.test.tsx b/superset-frontend/src/explore/components/ExploreChartHeader/ChartEditableTitle/ChartEditableTitle.test.tsx new file mode 100644 index 0000000000000..dd98518c8c41b --- /dev/null +++ b/superset-frontend/src/explore/components/ExploreChartHeader/ChartEditableTitle/ChartEditableTitle.test.tsx @@ -0,0 +1,68 @@ +/** + * 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 React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from 'spec/helpers/testing-library'; +import { ChartEditableTitle } from './index'; + +const createProps = (overrides: Record = {}) => ({ + title: 'Chart title', + placeholder: 'Add the name of the chart', + canEdit: true, + onSave: jest.fn(), + ...overrides, +}); + +describe('Chart editable title', () => { + it('renders chart title', () => { + const props = createProps(); + render(); + expect(screen.getByText('Chart title')).toBeVisible(); + }); + + it('renders placeholder', () => { + const props = createProps({ + title: '', + }); + render(); + expect(screen.getByText('Add the name of the chart')).toBeVisible(); + }); + + it('click, edit and save title', () => { + const props = createProps(); + render(); + const textboxElement = screen.getByRole('textbox'); + userEvent.click(textboxElement); + userEvent.type(textboxElement, ' edited'); + expect(screen.getByText('Chart title edited')).toBeVisible(); + userEvent.type(textboxElement, '{enter}'); + expect(props.onSave).toHaveBeenCalled(); + }); + + it('renders in non-editable mode', () => { + const props = createProps({ canEdit: false }); + render(); + const titleElement = screen.getByLabelText('Chart title'); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(titleElement).toBeVisible(); + userEvent.click(titleElement); + userEvent.type(titleElement, ' edited{enter}'); + expect(props.onSave).not.toHaveBeenCalled(); + }); +}); diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/ChartEditableTitle/index.tsx b/superset-frontend/src/explore/components/ExploreChartHeader/ChartEditableTitle/index.tsx new file mode 100644 index 0000000000000..0e2761b6a9dea --- /dev/null +++ b/superset-frontend/src/explore/components/ExploreChartHeader/ChartEditableTitle/index.tsx @@ -0,0 +1,213 @@ +/** + * 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 React, { + ChangeEvent, + KeyboardEvent, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { css, styled, t } from '@superset-ui/core'; +import { Tooltip } from 'src/components/Tooltip'; +import { useResizeDetector } from 'react-resize-detector'; + +export type ChartEditableTitleProps = { + title: string; + placeholder: string; + onSave: (title: string) => void; + canEdit: boolean; +}; + +const Styles = styled.div` + ${({ theme }) => css` + display: flex; + font-size: ${theme.typography.sizes.xl}px; + font-weight: ${theme.typography.weights.bold}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + & .chart-title, + & .chart-title-input { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + & .chart-title { + cursor: default; + } + & .chart-title-input { + border: none; + padding: 0; + outline: none; + + &::placeholder { + color: ${theme.colors.grayscale.light1}; + } + } + + & .input-sizer { + position: absolute; + left: -9999px; + display: inline-block; + } + `} +`; + +export const ChartEditableTitle = ({ + title, + placeholder, + onSave, + canEdit, +}: ChartEditableTitleProps) => { + const [isEditing, setIsEditing] = useState(false); + const [currentTitle, setCurrentTitle] = useState(title || ''); + const contentRef = useRef(null); + const [showTooltip, setShowTooltip] = useState(false); + + const { width: inputWidth, ref: sizerRef } = useResizeDetector(); + const { width: containerWidth, ref: containerRef } = useResizeDetector({ + refreshMode: 'debounce', + }); + + useEffect(() => { + if (isEditing && contentRef?.current) { + contentRef.current.focus(); + // move cursor and scroll to the end + if (contentRef.current.setSelectionRange) { + const { length } = contentRef.current.value; + contentRef.current.setSelectionRange(length, length); + contentRef.current.scrollLeft = contentRef.current.scrollWidth; + } + } + }, [isEditing]); + + // a trick to make the input grow when user types text + // we make additional span component, place it somewhere out of view and copy input + // then we can measure the width of that span to resize the input element + useLayoutEffect(() => { + if (sizerRef?.current) { + sizerRef.current.innerHTML = (currentTitle || placeholder).replace( + /\s/g, + ' ', + ); + } + }, [currentTitle, placeholder, sizerRef]); + + useEffect(() => { + if ( + contentRef.current && + contentRef.current.scrollWidth > contentRef.current.clientWidth + ) { + setShowTooltip(true); + } else { + setShowTooltip(false); + } + }, [inputWidth, containerWidth]); + + const handleClick = useCallback(() => { + if (!canEdit || isEditing) { + return; + } + setIsEditing(true); + }, [canEdit, isEditing]); + + const handleBlur = useCallback(() => { + if (!canEdit) { + return; + } + const formattedTitle = currentTitle.trim(); + setCurrentTitle(formattedTitle); + if (title !== formattedTitle) { + onSave(formattedTitle); + } + setIsEditing(false); + }, [canEdit, currentTitle, onSave, title]); + + const handleChange = useCallback( + (ev: ChangeEvent) => { + if (!canEdit || !isEditing) { + return; + } + setCurrentTitle(ev.target.value); + }, + [canEdit, isEditing], + ); + + const handleKeyPress = useCallback( + (ev: KeyboardEvent) => { + if (!canEdit) { + return; + } + if (ev.key === 'Enter') { + ev.preventDefault(); + contentRef.current?.blur(); + } + }, + [canEdit], + ); + + return ( + + + {canEdit ? ( + 0 && + css` + width: ${inputWidth}px; + `} + `} + /> + ) : ( + + {currentTitle} + + )} + + + + ); +}; diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx index 35dc9eb384e9d..8f298dce7676e 100644 --- a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx +++ b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx @@ -18,7 +18,6 @@ */ import React from 'react'; -import { Slice } from 'src/types/Chart'; import { render, screen } from 'spec/helpers/testing-library'; import userEvent from '@testing-library/user-event'; import ExploreHeader from '.'; @@ -80,7 +79,7 @@ const createProps = () => ({ slice_id: 318, slice_name: 'Age distribution of respondents', slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20318%7D', - } as unknown as Slice, + }, slice_name: 'Age distribution of respondents', actions: { postChartFormData: () => null, @@ -91,6 +90,7 @@ const createProps = () => ({ user: { userId: 1, }, + onSaveChart: jest.fn(), }); test('Cancelling changes to the properties should reset previous properties', () => { @@ -116,3 +116,17 @@ test('Cancelling changes to the properties should reset previous properties', () expect(screen.getByDisplayValue(prevChartName)).toBeInTheDocument(); }); + +test('Save chart', () => { + const props = createProps(); + render(, { useRedux: true }); + userEvent.click(screen.getByText('Save')); + expect(props.onSaveChart).toHaveBeenCalled(); +}); + +test('Save disabled', () => { + const props = createProps(); + render(, { useRedux: true }); + userEvent.click(screen.getByText('Save')); + expect(props.onSaveChart).not.toHaveBeenCalled(); +}); diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx index 665a2512ef273..1be9dd77a2bea 100644 --- a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx +++ b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx @@ -22,8 +22,8 @@ import { bindActionCreators } from 'redux'; import PropTypes from 'prop-types'; import { CategoricalColorNamespace, + css, SupersetClient, - styled, t, } from '@superset-ui/core'; import { @@ -33,28 +33,21 @@ import { } from 'src/reports/actions/reports'; import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import { chartPropShape } from 'src/dashboard/util/propShapes'; -import EditableTitle from 'src/components/EditableTitle'; import AlteredSliceTag from 'src/components/AlteredSliceTag'; import FaveStar from 'src/components/FaveStar'; -import Timer from 'src/components/Timer'; -import CachedLabel from 'src/components/CachedLabel'; +import Button from 'src/components/Button'; +import Icons from 'src/components/Icons'; import PropertiesModal from 'src/explore/components/PropertiesModal'; import { sliceUpdated } from 'src/explore/actions/exploreActions'; import CertifiedBadge from 'src/components/CertifiedBadge'; -import withToasts from 'src/components/MessageToasts/withToasts'; -import RowCountLabel from '../RowCountLabel'; +import { Tooltip } from 'src/components/Tooltip'; import ExploreAdditionalActionsMenu from '../ExploreAdditionalActionsMenu'; - -const CHART_STATUS_MAP = { - failed: 'danger', - loading: 'warning', - success: 'success', -}; +import { ChartEditableTitle } from './ChartEditableTitle'; const propTypes = { actions: PropTypes.object.isRequired, - can_overwrite: PropTypes.bool.isRequired, - can_download: PropTypes.bool.isRequired, + canOverwrite: PropTypes.bool.isRequired, + canDownload: PropTypes.bool.isRequired, dashboardId: PropTypes.number, isStarred: PropTypes.bool.isRequired, slice: PropTypes.object, @@ -64,14 +57,23 @@ const propTypes = { ownState: PropTypes.object, timeout: PropTypes.number, chart: chartPropShape, + saveDisabled: PropTypes.bool, }; -const StyledHeader = styled.div` +const saveButtonStyles = theme => css` + color: ${theme.colors.primary.dark2}; + & > span[role='img'] { + margin-right: 0; + } +`; + +const headerStyles = theme => css` display: flex; flex-direction: row; align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; justify-content: space-between; + height: 100%; span[role='button'] { display: flex; @@ -81,28 +83,32 @@ const StyledHeader = styled.div` .title-panel { display: flex; align-items: center; + min-width: 0; + margin-right: ${theme.gridUnit * 12}px; } .right-button-panel { display: flex; align-items: center; - - > .btn-group { - flex: 0 0 auto; - margin-left: ${({ theme }) => theme.gridUnit}px; - } - } - - .action-button { - color: ${({ theme }) => theme.colors.grayscale.base}; - margin: 0 ${({ theme }) => theme.gridUnit * 1.5}px 0 - ${({ theme }) => theme.gridUnit}px; } `; -const StyledButtons = styled.span` +const buttonsStyles = theme => css` display: flex; align-items: center; + padding-left: ${theme.gridUnit * 2}px; + + & .fave-unfave-icon { + padding: 0 ${theme.gridUnit}px; + + &:first-child { + padding-left: 0; + } + } +`; + +const saveButtonContainerStyles = theme => css` + margin-right: ${theme.gridUnit * 2}px; `; export class ExploreChartHeader extends React.PureComponent { @@ -173,13 +179,6 @@ export class ExploreChartHeader extends React.PureComponent { .catch(() => {}); } - getSliceName() { - const { sliceName, table_name: tableName } = this.props; - const title = sliceName || t('%s - untitled', tableName); - - return title; - } - postChartFormData() { this.props.actions.postChartFormData( this.props.form_data, @@ -221,50 +220,49 @@ export class ExploreChartHeader extends React.PureComponent { } render() { - const { user, form_data: formData, slice } = this.props; const { - chartStatus, - chartUpdateEndTime, - chartUpdateStartTime, - latestQueryFormData, - queriesResponse, - } = this.props.chart; - // TODO: when will get appropriate design for multi queries use all results and not first only - const queryResponse = queriesResponse?.[0]; - const chartFinished = ['failed', 'rendered', 'success'].includes( - this.props.chart.chartStatus, - ); + actions, + chart, + user, + formData, + slice, + canOverwrite, + canDownload, + isStarred, + sliceUpdated, + sliceName, + onSaveChart, + saveDisabled, + } = this.props; + const { latestQueryFormData, sliceFormData } = chart; + const oldSliceName = slice?.slice_name; return ( - +
- {slice?.certified_by && ( - <> - {' '} - - )} - - - {this.props.slice && ( - + {slice && ( + + {slice.certified_by && ( + + )} {user.userId && ( )} @@ -272,49 +270,52 @@ export class ExploreChartHeader extends React.PureComponent { )} - {this.props.chart.sliceFormData && ( + {sliceFormData && ( )} - + )}
- {chartFinished && queryResponse && ( - - )} - {chartFinished && queryResponse && queryResponse.is_cached && ( - - )} - + + {/* needed to wrap button in a div - antd tooltip doesn't work with disabled button */} +
+ +
+
- +
); } } @@ -328,7 +329,4 @@ function mapDispatchToProps(dispatch) { ); } -export default connect( - null, - mapDispatchToProps, -)(withToasts(ExploreChartHeader)); +export default connect(null, mapDispatchToProps)(ExploreChartHeader); diff --git a/superset-frontend/src/explore/components/ExploreChartPanel.jsx b/superset-frontend/src/explore/components/ExploreChartPanel.jsx index 2067d853c7fdf..5cd818f52ee89 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel.jsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel.jsx @@ -19,7 +19,14 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import PropTypes from 'prop-types'; import Split from 'react-split'; -import { styled, SupersetClient, useTheme } from '@superset-ui/core'; +import { + css, + ensureIsArray, + styled, + SupersetClient, + t, + useTheme, +} from '@superset-ui/core'; import { useResizeDetector } from 'react-resize-detector'; import { chartPropShape } from 'src/dashboard/util/propShapes'; import ChartContainer from 'src/components/Chart/ChartContainer'; @@ -28,9 +35,11 @@ import { setItem, LocalStorageKeys, } from 'src/utils/localStorageHelpers'; -import ConnectedExploreChartHeader from './ExploreChartHeader'; import { DataTablesPane } from './DataTablesPane'; import { buildV1ChartDataPayload } from '../exploreUtils'; +import { ChartPills } from './ChartPills'; +import { ExploreAlert } from './ExploreAlert'; +import { getChartRequiredFieldsMissingMessage } from '../../utils/getChartRequiredFieldsMissingMessage'; const propTypes = { actions: PropTypes.object.isRequired, @@ -41,8 +50,6 @@ const propTypes = { dashboardId: PropTypes.number, column_formats: PropTypes.object, containerId: PropTypes.string.isRequired, - height: PropTypes.string.isRequired, - width: PropTypes.string.isRequired, isStarred: PropTypes.bool.isRequired, slice: PropTypes.object, sliceName: PropTypes.string, @@ -53,7 +60,7 @@ const propTypes = { standalone: PropTypes.number, force: PropTypes.bool, timeout: PropTypes.number, - refreshOverlayVisible: PropTypes.bool, + chartIsStale: PropTypes.bool, chart: chartPropShape, errorMessage: PropTypes.node, triggerRender: PropTypes.bool, @@ -61,12 +68,8 @@ const propTypes = { const GUTTER_SIZE_FACTOR = 1.25; -const CHART_PANEL_PADDING_HORIZ = 30; -const CHART_PANEL_PADDING_VERTICAL = 15; -const HEADER_PADDING = 15; - -const INITIAL_SIZES = [90, 10]; -const MIN_SIZES = [300, 50]; +const INITIAL_SIZES = [100, 0]; +const MIN_SIZES = [300, 65]; const DEFAULT_SOUTH_PANE_HEIGHT_PERCENT = 40; const Styles = styled.div` @@ -78,8 +81,8 @@ const Styles = styled.div` box-shadow: none; height: 100%; - & > div:last-of-type { - flex-basis: 100%; + & > div { + height: 100%; } .gutter { @@ -110,28 +113,50 @@ const Styles = styled.div` } `; -const ExploreChartPanel = props => { +const ExploreChartPanel = ({ + chart, + slice, + vizType, + ownState, + triggerRender, + force, + datasource, + errorMessage, + form_data: formData, + onQuery, + actions, + timeout, + standalone, + chartIsStale, + chartAlert, +}) => { const theme = useTheme(); const gutterMargin = theme.gridUnit * GUTTER_SIZE_FACTOR; const gutterHeight = theme.gridUnit * GUTTER_SIZE_FACTOR; - const { height: hHeight, ref: headerRef } = useResizeDetector({ - refreshMode: 'debounce', - refreshRate: 300, - }); - const { width: chartPanelWidth, ref: chartPanelRef } = useResizeDetector({ + const { + width: chartPanelWidth, + height: chartPanelHeight, + ref: chartPanelRef, + } = useResizeDetector({ refreshMode: 'debounce', refreshRate: 300, }); const [splitSizes, setSplitSizes] = useState( getItem(LocalStorageKeys.chart_split_sizes, INITIAL_SIZES), ); - const { slice } = props; + + const showAlertBanner = + !chartAlert && + chartIsStale && + chart.chartStatus !== 'failed' && + ensureIsArray(chart.queriesResponse).length > 0; + const updateQueryContext = useCallback( async function fetchChartData() { if (slice && slice.query_context === null) { const queryContext = buildV1ChartDataPayload({ formData: slice.form_data, - force: props.force, + force, resultFormat: 'json', resultType: 'full', setDataMask: null, @@ -155,50 +180,28 @@ const ExploreChartPanel = props => { updateQueryContext(); }, [updateQueryContext]); - const calcSectionHeight = useCallback( - percent => { - let headerHeight; - if (props.standalone) { - headerHeight = 0; - } else if (hHeight) { - headerHeight = hHeight + HEADER_PADDING; - } else { - headerHeight = 50; - } - const containerHeight = parseInt(props.height, 10) - headerHeight; - return ( - (containerHeight * percent) / 100 - (gutterHeight / 2 + gutterMargin) - ); - }, - [gutterHeight, gutterMargin, props.height, props.standalone, hHeight], - ); - - const [tableSectionHeight, setTableSectionHeight] = useState( - calcSectionHeight(INITIAL_SIZES[1]), - ); - - const recalcPanelSizes = useCallback( - ([, southPercent]) => { - setTableSectionHeight(calcSectionHeight(southPercent)); - }, - [calcSectionHeight], - ); - - useEffect(() => { - recalcPanelSizes(splitSizes); - }, [recalcPanelSizes, splitSizes]); - useEffect(() => { setItem(LocalStorageKeys.chart_split_sizes, splitSizes); }, [splitSizes]); - const onDragEnd = sizes => { + const onDragEnd = useCallback(sizes => { setSplitSizes(sizes); - }; + }, []); + + const refreshCachedQuery = useCallback(() => { + actions.postChartFormData( + formData, + true, + timeout, + chart.id, + undefined, + ownState, + ); + }, [actions, chart.id, formData, ownState, timeout]); - const onCollapseChange = openPanelName => { + const onCollapseChange = useCallback(isOpen => { let splitSizes; - if (!openPanelName) { + if (!isOpen) { splitSizes = INITIAL_SIZES; } else { splitSizes = [ @@ -207,59 +210,135 @@ const ExploreChartPanel = props => { ]; } setSplitSizes(splitSizes); - }; - const renderChart = useCallback(() => { - const { chart, vizType } = props; - const newHeight = - vizType === 'filter_box' - ? calcSectionHeight(100) - CHART_PANEL_PADDING_VERTICAL - : calcSectionHeight(splitSizes[0]) - CHART_PANEL_PADDING_VERTICAL; - const chartWidth = chartPanelWidth - CHART_PANEL_PADDING_HORIZ; - return ( - chartWidth > 0 && ( - - ) - ); - }, [calcSectionHeight, chartPanelWidth, props, splitSizes]); + }, []); + + const renderChart = useCallback( + () => ( +
+ {chartPanelWidth && chartPanelHeight && ( + + )} +
+ ), + [ + actions.setControlValue, + chart.annotationData, + chart.chartAlert, + chart.chartStackTrace, + chart.chartStatus, + chart.id, + chart.latestQueryFormData, + chart.queriesResponse, + chart.triggerQuery, + chartIsStale, + chartPanelHeight, + chartPanelRef, + chartPanelWidth, + datasource, + errorMessage, + force, + formData, + onQuery, + ownState, + timeout, + triggerRender, + vizType, + ], + ); const panelBody = useMemo( () => ( -
+
+ {showAlertBanner && ( + + {t( + 'You updated the values in the control panel, but the chart was not updated automatically. Run the query by clicking on the "Update chart" button or', + )}{' '} + + {t('click here')} + + . + + ) + } + type="warning" + css={theme => css` + margin: 0 0 ${theme.gridUnit * 4}px 0; + `} + /> + )} + {renderChart()}
), - [chartPanelRef, renderChart], + [ + showAlertBanner, + errorMessage, + onQuery, + chart.queriesResponse, + chart.chartStatus, + chart.chartUpdateStartTime, + chart.chartUpdateEndTime, + refreshCachedQuery, + formData?.row_limit, + renderChart, + ], ); - const standaloneChartBody = useMemo( - () =>
{renderChart()}
, - [chartPanelRef, renderChart], - ); + const standaloneChartBody = useMemo(() => renderChart(), [renderChart]); - const [queryFormData, setQueryFormData] = useState( - props.chart.latestQueryFormData, - ); + const [queryFormData, setQueryFormData] = useState(chart.latestQueryFormData); useEffect(() => { // only update when `latestQueryFormData` changes AND `triggerRender` @@ -267,13 +346,20 @@ const ExploreChartPanel = props => { // as this can trigger a query downstream based on incomplete form data. // (`latestQueryFormData` is only updated when a a valid request has been // triggered). - if (!props.triggerRender) { - setQueryFormData(props.chart.latestQueryFormData); + if (!triggerRender) { + setQueryFormData(chart.latestQueryFormData); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.chart.latestQueryFormData]); + }, [chart.latestQueryFormData]); + + const elementStyle = useCallback( + (dimension, elementSize, gutterSize) => ({ + [dimension]: `calc(${elementSize}% - ${gutterSize + gutterMargin}px)`, + }), + [gutterMargin], + ); - if (props.standalone) { + if (standalone) { // dom manipulation hack to get rid of the boostrap theme's body background const standaloneClass = 'background-transparent'; const bodyClasses = document.body.className.split(' '); @@ -283,35 +369,9 @@ const ExploreChartPanel = props => { return standaloneChartBody; } - const header = ( - - ); - - const elementStyle = (dimension, elementSize, gutterSize) => ({ - [dimension]: `calc(${elementSize}% - ${gutterSize + gutterMargin}px)`, - }); - return ( - -
- {header} -
- {props.vizType === 'filter_box' ? ( + + {vizType === 'filter_box' ? ( panelBody ) : ( { gutterSize={gutterHeight} onDragEnd={onDragEnd} elementStyle={elementStyle} + expandToMin > {panelBody} )} diff --git a/superset-frontend/src/explore/components/ExploreChartPanel.test.jsx b/superset-frontend/src/explore/components/ExploreChartPanel.test.jsx index c50a605a40aae..a779773052e69 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel.test.jsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel.test.jsx @@ -17,23 +17,70 @@ * under the License. */ import React from 'react'; - +import { render, screen } from 'spec/helpers/testing-library'; import ChartContainer from 'src/explore/components/ExploreChartPanel'; -describe('ChartContainer', () => { - const mockProps = { - sliceName: 'Trend Line', - vizType: 'line', - height: '500px', - actions: {}, - can_overwrite: false, - can_download: false, - containerId: 'foo', - width: '50px', - isStarred: false, - }; +const createProps = (overrides = {}) => ({ + sliceName: 'Trend Line', + vizType: 'line', + height: '500px', + actions: {}, + can_overwrite: false, + can_download: false, + containerId: 'foo', + width: '500px', + isStarred: false, + chartIsStale: false, + chart: {}, + form_data: {}, + ...overrides, +}); +describe('ChartContainer', () => { it('renders when vizType is line', () => { - expect(React.isValidElement()).toBe(true); + const props = createProps(); + expect(React.isValidElement()).toBe(true); + }); + + it('renders with alert banner', () => { + const props = createProps({ + chartIsStale: true, + chart: { chartStatus: 'rendered', queriesResponse: [{}] }, + }); + render(, { useRedux: true }); + expect(screen.getByText('Your chart is not up to date')).toBeVisible(); + }); + + it('doesnt render alert banner when no changes in control panel were made (chart is not stale)', () => { + const props = createProps({ + chartIsStale: false, + }); + render(, { useRedux: true }); + expect( + screen.queryByText('Your chart is not up to date'), + ).not.toBeInTheDocument(); + }); + + it('doesnt render alert banner when chart not created yet (no queries response)', () => { + const props = createProps({ + chartIsStale: true, + chart: { queriesResponse: [] }, + }); + render(, { useRedux: true }); + expect( + screen.queryByText('Your chart is not up to date'), + ).not.toBeInTheDocument(); + }); + + it('renders prompt to fill required controls when required control removed', () => { + const props = createProps({ + chartIsStale: true, + chart: { chartStatus: 'rendered', queriesResponse: [{}] }, + errorMessage: 'error', + }); + render(, { useRedux: true }); + expect( + screen.getByText('Required control values have been removed'), + ).toBeVisible(); }); }); diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx index da815ca7dc32c..7743997a35529 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx @@ -18,6 +18,7 @@ */ import React from 'react'; import fetchMock from 'fetch-mock'; +import { getChartControlPanelRegistry } from '@superset-ui/core'; import { MemoryRouter, Route } from 'react-router-dom'; import { render, screen, waitFor } from 'spec/helpers/testing-library'; import userEvent from '@testing-library/user-event'; @@ -26,7 +27,10 @@ import ExploreViewContainer from '.'; const reduxState = { explore: { common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 60 } }, - controls: { datasource: { value: '1__table' } }, + controls: { + datasource: { value: '1__table' }, + viz_type: { value: 'table' }, + }, datasource: { id: 1, type: 'table', @@ -111,13 +115,17 @@ test('generates a different form_data param when one is provided and is mounting }); test('reuses the same form_data param when updating', async () => { + getChartControlPanelRegistry().registerValue('table', { + controlPanelSections: [], + }); const replaceState = jest.spyOn(window.history, 'replaceState'); const pushState = jest.spyOn(window.history, 'pushState'); await waitFor(() => renderWithRouter()); expect(replaceState.mock.calls.length).toBe(1); - userEvent.click(screen.getByText('Run')); + userEvent.click(screen.getByText('Update chart')); await waitFor(() => expect(pushState.mock.calls.length).toBe(1)); expect(replaceState.mock.calls[0]).toEqual(pushState.mock.calls[0]); replaceState.mockRestore(); pushState.mockRestore(); + getChartControlPanelRegistry().remove('table'); }); diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index 527392275c51c..da18dcc4ff5c5 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -22,7 +22,7 @@ import PropTypes from 'prop-types'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; import { styled, t, css, useTheme, logging } from '@superset-ui/core'; -import { debounce } from 'lodash'; +import { debounce, pick } from 'lodash'; import { Resizable } from 're-resizable'; import { useChangeEffect } from 'src/hooks/useChangeEffect'; import { usePluginContext } from 'src/components/DynamicPlugins'; @@ -48,7 +48,6 @@ import { useTabId } from 'src/hooks/useTabId'; import ExploreChartPanel from '../ExploreChartPanel'; import ConnectedControlPanelsContainer from '../ControlPanelsContainer'; import SaveModal from '../SaveModal'; -import QueryAndSaveBtns from '../QueryAndSaveBtns'; import DataSourcePanel from '../DatasourcePanel'; import { mountExploreUrl } from '../../exploreUtils'; import { areObjectsEqual } from '../../../reduxUtils'; @@ -60,11 +59,10 @@ import { LOG_ACTIONS_MOUNT_EXPLORER, LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS, } from '../../../logger/LogUtils'; +import ConnectedExploreChartHeader from '../ExploreChartHeader'; const propTypes = { ...ExploreChartPanel.propTypes, - height: PropTypes.string, - width: PropTypes.string, actions: PropTypes.object.isRequired, datasource_type: PropTypes.string.isRequired, dashboardId: PropTypes.number, @@ -82,88 +80,97 @@ const propTypes = { vizType: PropTypes.string, }; -const Styles = styled.div` - background: ${({ theme }) => theme.colors.grayscale.light5}; - text-align: left; - position: relative; - width: 100%; - max-height: 100%; +const ExploreContainer = styled.div` display: flex; - flex-direction: row; - flex-wrap: nowrap; - flex-basis: 100vh; - align-items: stretch; - border-top: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; - .explore-column { - display: flex; - flex-direction: column; - padding: ${({ theme }) => 2 * theme.gridUnit}px 0; - max-height: 100%; - } - .data-source-selection { - background-color: ${({ theme }) => theme.colors.grayscale.light5}; - padding: ${({ theme }) => 2 * theme.gridUnit}px 0; - border-right: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; - } - .main-explore-content { - flex: 1; - min-width: ${({ theme }) => theme.gridUnit * 128}px; - border-left: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; - .panel { - margin-bottom: 0; + flex-direction: column; + height: 100%; +`; + +const ExploreHeaderContainer = styled.div` + ${({ theme }) => css` + background-color: ${theme.colors.grayscale.light5}; + height: ${theme.gridUnit * 16}px; + padding: 0 ${theme.gridUnit * 4}px; + + .editable-title { + overflow: hidden; + + & > input[type='button'], + & > span { + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + white-space: nowrap; + } } - } - .controls-column { - align-self: flex-start; - padding: 0; - } - .title-container { + `} +`; + +const ExplorePanelContainer = styled.div` + ${({ theme }) => css` + background: ${theme.colors.grayscale.light5}; + text-align: left; position: relative; + width: 100%; + max-height: 100%; + min-height: 0; display: flex; - flex-direction: row; - padding: 0 ${({ theme }) => 2 * theme.gridUnit}px; - justify-content: space-between; - .horizontal-text { - text-transform: uppercase; - color: ${({ theme }) => theme.colors.grayscale.light1}; - font-size: ${({ theme }) => 4 * theme.typography.sizes.s}; + flex: 1; + flex-wrap: nowrap; + border-top: 1px solid ${theme.colors.grayscale.light2}; + .explore-column { + display: flex; + flex-direction: column; + padding: ${theme.gridUnit * 2}px 0; + max-height: 100%; } - } - .no-show { - display: none; - } - .vertical-text { - writing-mode: vertical-rl; - text-orientation: mixed; - } - .sidebar { - height: 100%; - background-color: ${({ theme }) => theme.colors.grayscale.light4}; - padding: ${({ theme }) => 2 * theme.gridUnit}px; - width: ${({ theme }) => 8 * theme.gridUnit}px; - } - .callpase-icon > svg { - color: ${({ theme }) => theme.colors.primary.base}; - } + .data-source-selection { + background-color: ${theme.colors.grayscale.light5}; + padding: ${theme.gridUnit * 2}px 0; + border-right: 1px solid ${theme.colors.grayscale.light2}; + } + .main-explore-content { + flex: 1; + min-width: ${theme.gridUnit * 128}px; + border-left: 1px solid ${theme.colors.grayscale.light2}; + padding: 0 ${theme.gridUnit * 4}px; + .panel { + margin-bottom: 0; + } + } + .controls-column { + align-self: flex-start; + padding: 0; + } + .title-container { + position: relative; + display: flex; + flex-direction: row; + padding: 0 ${theme.gridUnit * 4}px; + justify-content: space-between; + .horizontal-text { + font-size: ${theme.typography.sizes.s}px; + } + } + .no-show { + display: none; + } + .vertical-text { + writing-mode: vertical-rl; + text-orientation: mixed; + } + .sidebar { + height: 100%; + background-color: ${theme.colors.grayscale.light4}; + padding: ${theme.gridUnit * 2}px; + width: ${theme.gridUnit * 8}px; + } + .callpase-icon > svg { + color: ${theme.colors.primary.base}; + } + `}; `; -const getWindowSize = () => ({ - height: window.innerHeight, - width: window.innerWidth, -}); - -function useWindowSize({ delayMs = 250 } = {}) { - const [size, setSize] = useState(getWindowSize()); - - useEffect(() => { - const onWindowResize = debounce(() => setSize(getWindowSize()), delayMs); - window.addEventListener('resize', onWindowResize); - return () => window.removeEventListener('resize', onWindowResize); - }, []); - - return size; -} - const updateHistory = debounce( async (formData, datasetId, isReplace, standalone, force, title, tabId) => { const payload = { ...formData }; @@ -221,7 +228,6 @@ function ExploreViewContainer(props) { const [lastQueriedControls, setLastQueriedControls] = useState( props.controls, ); - const windowSize = useWindowSize(); const [showingModal, setShowingModal] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false); @@ -229,11 +235,6 @@ function ExploreViewContainer(props) { const tabId = useTabId(); const theme = useTheme(); - const width = `${windowSize.width}px`; - const navHeight = props.standalone ? 0 : 90; - const height = props.forcedHeight - ? `${props.forcedHeight}px` - : `${windowSize.height - navHeight}px`; const defaultSidebarsWidth = { controls_width: 320, @@ -380,18 +381,33 @@ function ExploreViewContainer(props) { } }, []); - const reRenderChart = () => { - props.actions.updateQueryFormData( - getFormDataFromControls(props.controls), + const reRenderChart = useCallback( + controlsChanged => { + const newQueryFormData = controlsChanged + ? { + ...props.chart.latestQueryFormData, + ...getFormDataFromControls(pick(props.controls, controlsChanged)), + } + : getFormDataFromControls(props.controls); + props.actions.updateQueryFormData(newQueryFormData, props.chart.id); + props.actions.renderTriggered(new Date().getTime(), props.chart.id); + addHistory(); + }, + [ + addHistory, + props.actions, props.chart.id, - ); - props.actions.renderTriggered(new Date().getTime(), props.chart.id); - addHistory(); - }; + props.chart.latestQueryFormData, + props.controls, + ], + ); // effect to run when controls change useEffect(() => { - if (previousControls) { + if ( + previousControls && + props.chart.latestQueryFormData.viz_type === props.controls.viz_type.value + ) { if ( props.controls.datasource && (previousControls.datasource == null || @@ -411,11 +427,11 @@ function ExploreViewContainer(props) { ); // this should also be handled by the actions that are actually changing the controls - const hasDisplayControlChanged = changedControlKeys.some( + const displayControlsChanged = changedControlKeys.filter( key => props.controls[key].renderTrigger, ); - if (hasDisplayControlChanged) { - reRenderChart(); + if (displayControlsChanged.length > 0) { + reRenderChart(displayControlsChanged); } } }, [props.controls, props.ownState]); @@ -451,8 +467,7 @@ function ExploreViewContainer(props) { props.actions.logEvent(LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS); } - function renderErrorMessage() { - // Returns an error message as a node if any errors are in the store + const errorMessage = useMemo(() => { const controlsWithErrors = Object.values(props.controls).filter( control => control.validationErrors && control.validationErrors.length > 0, @@ -486,16 +501,14 @@ function ExploreViewContainer(props) { errorMessage =
{errors}
; } return errorMessage; - } + }, [props.controls]); function renderChartContainer() { return ( ); @@ -515,144 +528,161 @@ function ExploreViewContainer(props) { } return ( - - - {showingModal && ( - + + - )} - { - setShouldForceUpdate(d?.width); - setSidebarWidths(LocalStorageKeys.datasource_width, d); - }} - defaultSize={{ - width: getSidebarWidths(LocalStorageKeys.datasource_width), - height: '100%', - }} - minWidth={defaultSidebarsWidth[LocalStorageKeys.datasource_width]} - maxWidth="33%" - enable={{ right: true }} - className={ - isCollapsed ? 'no-show' : 'explore-column data-source-selection' - } - > -
- {t('Dataset')} - - - -
- + + -
- {isCollapsed ? ( -
+ )} + { + setShouldForceUpdate(d?.width); + setSidebarWidths(LocalStorageKeys.datasource_width, d); + }} + defaultSize={{ + width: getSidebarWidths(LocalStorageKeys.datasource_width), + height: '100%', + }} + minWidth={defaultSidebarsWidth[LocalStorageKeys.datasource_width]} + maxWidth="33%" + enable={{ right: true }} + className={ + isCollapsed ? 'no-show' : 'explore-column data-source-selection' + } > - - - + {t('Dataset')} + + - - - +
+ + + {isCollapsed ? ( +
+ + + + + + +
+ ) : null} + + setSidebarWidths(LocalStorageKeys.controls_width, d) + } + defaultSize={{ + width: getSidebarWidths(LocalStorageKeys.controls_width), + height: '100%', + }} + minWidth={defaultSidebarsWidth[LocalStorageKeys.controls_width]} + maxWidth="33%" + enable={{ right: true }} + className="col-sm-3 explore-column controls-column" + > + + +
+ {renderChartContainer()}
- ) : null} - - setSidebarWidths(LocalStorageKeys.controls_width, d) - } - defaultSize={{ - width: getSidebarWidths(LocalStorageKeys.controls_width), - height: '100%', - }} - minWidth={defaultSidebarsWidth[LocalStorageKeys.controls_width]} - maxWidth="33%" - enable={{ right: true }} - className="col-sm-3 explore-column controls-column" - > - - - -
- {renderChartContainer()} -
-
+ + ); } diff --git a/superset-frontend/src/explore/components/PropertiesModal/PropertiesModal.test.tsx b/superset-frontend/src/explore/components/PropertiesModal/PropertiesModal.test.tsx index f9cdca0277faf..4ea1327603311 100644 --- a/superset-frontend/src/explore/components/PropertiesModal/PropertiesModal.test.tsx +++ b/superset-frontend/src/explore/components/PropertiesModal/PropertiesModal.test.tsx @@ -18,57 +18,27 @@ */ import React from 'react'; -import { Slice } from 'src/types/Chart'; import { render, screen, waitFor } from 'spec/helpers/testing-library'; import fetchMock from 'fetch-mock'; import userEvent from '@testing-library/user-event'; import PropertiesModal, { PropertiesModalProps } from '.'; -const createProps = () => ({ - slice: { - cache_timeout: null, - certified_by: 'John Doe', - certification_details: 'Sample certification', - changed_on: '2021-03-19T16:30:56.750230', - changed_on_humanized: '7 days ago', - datasource: 'FCC 2018 Survey', - description: null, - description_markeddown: '', - edit_url: '/chart/edit/318', - form_data: { - adhoc_filters: [], - all_columns_x: ['age'], - color_scheme: 'supersetColors', - datasource: '49__table', - granularity_sqla: 'time_start', - groupby: null, - label_colors: {}, - link_length: '25', - queryFields: { groupby: 'groupby' }, - row_limit: 10000, +const createProps = () => + ({ + slice: { + cache_timeout: null, + certified_by: 'John Doe', + certification_details: 'Sample certification', + description: null, slice_id: 318, - time_range: 'No filter', - url_params: {}, - viz_type: 'histogram', - x_axis_label: 'age', - y_axis_label: 'count', + slice_name: 'Age distribution of respondents', + is_managed_externally: false, }, - modified: '7 days ago', - owners: [ - { - text: 'Superset Admin', - value: 1, - }, - ], - slice_id: 318, - slice_name: 'Age distribution of respondents', - slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20318%7D', - } as unknown as Slice, - show: true, - onHide: jest.fn(), - onSave: jest.fn(), - addSuccessToast: jest.fn(), -}); + show: true, + onHide: jest.fn(), + onSave: jest.fn(), + addSuccessToast: jest.fn(), + } as PropertiesModalProps); fetchMock.get('glob:*/api/v1/chart/318', { body: { diff --git a/superset-frontend/src/explore/components/QueryAndSaveBtns.test.jsx b/superset-frontend/src/explore/components/QueryAndSaveBtns.test.jsx deleted file mode 100644 index 12e1a95e8ef75..0000000000000 --- a/superset-frontend/src/explore/components/QueryAndSaveBtns.test.jsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import React from 'react'; -import { styledMount as mount } from 'spec/helpers/theming'; -import sinon from 'sinon'; - -import QueryAndSaveButtons from 'src/explore/components/QueryAndSaveBtns'; -import Button from 'src/components/Button'; - -describe('QueryAndSaveButtons', () => { - const defaultProps = { - canAdd: true, - onQuery: sinon.spy(), - }; - - // It must render - it('renders', () => { - expect( - React.isValidElement(), - ).toBe(true); - }); - - // Test the output - describe('output', () => { - const wrapper = mount(); - - it('renders 2 buttons', () => { - expect(wrapper.find(Button)).toHaveLength(2); - }); - - it('renders buttons with correct text', () => { - expect(wrapper.find(Button).at(0).text().trim()).toBe('Run'); - expect(wrapper.find(Button).at(1).text().trim()).toBe('Save'); - }); - - it('calls onQuery when query button is clicked', () => { - const queryButton = wrapper - .find('[data-test="run-query-button"]') - .hostNodes(); - queryButton.simulate('click'); - expect(defaultProps.onQuery.called).toBe(true); - }); - }); -}); diff --git a/superset-frontend/src/explore/components/QueryAndSaveBtns.tsx b/superset-frontend/src/explore/components/QueryAndSaveBtns.tsx deleted file mode 100644 index 91366580b3735..0000000000000 --- a/superset-frontend/src/explore/components/QueryAndSaveBtns.tsx +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import React from 'react'; -import ButtonGroup from 'src/components/ButtonGroup'; -import { t, useTheme } from '@superset-ui/core'; - -import { Tooltip } from 'src/components/Tooltip'; -import Button, { ButtonStyle, OnClickHandler } from 'src/components/Button'; - -export type QueryAndSaveBtnsProps = { - canAdd: boolean; - onQuery: OnClickHandler; - onSave: OnClickHandler; - onStop: OnClickHandler; - loading?: boolean; - chartIsStale?: boolean; - errorMessage: React.ReactElement | undefined; -}; - -export default function QueryAndSaveBtns(props: QueryAndSaveBtnsProps) { - const { - canAdd, - onQuery = () => {}, - onSave = () => {}, - onStop = () => {}, - loading, - chartIsStale, - errorMessage, - } = props; - let qryButtonStyle: ButtonStyle = 'tertiary'; - if (errorMessage) { - qryButtonStyle = 'danger'; - } else if (chartIsStale) { - qryButtonStyle = 'primary'; - } - - const saveButtonDisabled = errorMessage ? true : loading; - const qryOrStopButton = loading ? ( - - ) : ( - - ); - - const theme = useTheme(); - - return ( -
- - {qryOrStopButton} - - - {errorMessage && ( - - {' '} - - - - - )} -
- ); -} diff --git a/superset-frontend/src/explore/components/RowCountLabel.stories.tsx b/superset-frontend/src/explore/components/RowCountLabel/RowCountLabel.stories.tsx similarity index 97% rename from superset-frontend/src/explore/components/RowCountLabel.stories.tsx rename to superset-frontend/src/explore/components/RowCountLabel/RowCountLabel.stories.tsx index 9030b828d8440..716830f9ca32b 100644 --- a/superset-frontend/src/explore/components/RowCountLabel.stories.tsx +++ b/superset-frontend/src/explore/components/RowCountLabel/RowCountLabel.stories.tsx @@ -17,7 +17,7 @@ * under the License. */ import React from 'react'; -import RowCountLabel from './RowCountLabel'; +import RowCountLabel from '.'; export default { title: 'RowCountLabel', diff --git a/superset-frontend/src/explore/components/RowCountLabel.test.jsx b/superset-frontend/src/explore/components/RowCountLabel/RowCountLabel.test.jsx similarity index 96% rename from superset-frontend/src/explore/components/RowCountLabel.test.jsx rename to superset-frontend/src/explore/components/RowCountLabel/RowCountLabel.test.jsx index 128a0329c298f..452f68a745478 100644 --- a/superset-frontend/src/explore/components/RowCountLabel.test.jsx +++ b/superset-frontend/src/explore/components/RowCountLabel/RowCountLabel.test.jsx @@ -21,7 +21,7 @@ import { shallow } from 'enzyme'; import Label from 'src/components/Label'; import { Tooltip } from 'src/components/Tooltip'; -import RowCountLabel from 'src/explore/components/RowCountLabel'; +import RowCountLabel from '.'; describe('RowCountLabel', () => { const defaultProps = { diff --git a/superset-frontend/src/explore/components/RowCountLabel.tsx b/superset-frontend/src/explore/components/RowCountLabel/index.tsx similarity index 100% rename from superset-frontend/src/explore/components/RowCountLabel.tsx rename to superset-frontend/src/explore/components/RowCountLabel/index.tsx diff --git a/superset-frontend/src/explore/components/QueryAndSaveBtns.stories.tsx b/superset-frontend/src/explore/components/RunQueryButton/RunQueryButton.stories.tsx similarity index 69% rename from superset-frontend/src/explore/components/QueryAndSaveBtns.stories.tsx rename to superset-frontend/src/explore/components/RunQueryButton/RunQueryButton.stories.tsx index 1f8d999107c4d..98c36bed2288e 100644 --- a/superset-frontend/src/explore/components/QueryAndSaveBtns.stories.tsx +++ b/superset-frontend/src/explore/components/RunQueryButton/RunQueryButton.stories.tsx @@ -17,29 +17,31 @@ * under the License. */ import React from 'react'; -import QueryAndSaveBtns, { QueryAndSaveBtnsProps } from './QueryAndSaveBtns'; +import { RunQueryButton, RunQueryButtonProps } from '.'; export default { - title: 'QueryAndSaveBtns', - component: QueryAndSaveBtns, + title: 'RunQueryButton', + component: RunQueryButton, }; -export const InteractiveQueryAndSaveBtnsProps = ( - args: QueryAndSaveBtnsProps, -) => ; +export const InteractiveRunQueryButtonProps = (args: RunQueryButtonProps) => ( + +); -InteractiveQueryAndSaveBtnsProps.args = { - canAdd: true, +InteractiveRunQueryButtonProps.args = { + canStopQuery: true, loading: false, + errorMessage: null, + isNewChart: false, + chartIsStale: true, }; -InteractiveQueryAndSaveBtnsProps.argTypes = { +InteractiveRunQueryButtonProps.argTypes = { onQuery: { action: 'onQuery' }, - onSave: { action: 'onSave' }, onStop: { action: 'onStop' }, }; -InteractiveQueryAndSaveBtnsProps.story = { +InteractiveRunQueryButtonProps.story = { parameters: { knobs: { disable: true, diff --git a/superset-frontend/src/explore/components/RunQueryButton/RunQueryButton.test.tsx b/superset-frontend/src/explore/components/RunQueryButton/RunQueryButton.test.tsx new file mode 100644 index 0000000000000..2e298f5c02992 --- /dev/null +++ b/superset-frontend/src/explore/components/RunQueryButton/RunQueryButton.test.tsx @@ -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 React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from 'spec/helpers/testing-library'; +import { RunQueryButton } from './index'; + +const createProps = (overrides: Record = {}) => ({ + loading: false, + onQuery: jest.fn(), + onStop: jest.fn(), + errorMessage: null, + isNewChart: false, + canStopQuery: true, + chartIsStale: true, + ...overrides, +}); + +test('renders update chart button', () => { + const props = createProps(); + render(); + expect(screen.getByText('Update chart')).toBeVisible(); + userEvent.click(screen.getByRole('button')); + expect(props.onQuery).toHaveBeenCalled(); +}); + +test('renders create chart button', () => { + const props = createProps({ isNewChart: true }); + render(); + expect(screen.getByText('Create chart')).toBeVisible(); + userEvent.click(screen.getByRole('button')); + expect(props.onQuery).toHaveBeenCalled(); +}); + +test('renders disabled button', () => { + const props = createProps({ errorMessage: 'error' }); + render(); + expect(screen.getByText('Update chart')).toBeVisible(); + expect(screen.getByRole('button')).toBeDisabled(); + userEvent.click(screen.getByRole('button')); + expect(props.onQuery).not.toHaveBeenCalled(); +}); + +test('renders query running button', () => { + const props = createProps({ loading: true }); + render(); + expect(screen.getByText('Stop')).toBeVisible(); + userEvent.click(screen.getByRole('button')); + expect(props.onStop).toHaveBeenCalled(); +}); + +test('renders query running button disabled', () => { + const props = createProps({ loading: true, canStopQuery: false }); + render(); + expect(screen.getByText('Stop')).toBeVisible(); + expect(screen.getByRole('button')).toBeDisabled(); + userEvent.click(screen.getByRole('button')); + expect(props.onStop).not.toHaveBeenCalled(); +}); diff --git a/superset-frontend/src/explore/components/RunQueryButton/index.tsx b/superset-frontend/src/explore/components/RunQueryButton/index.tsx new file mode 100644 index 0000000000000..622cb516f00bf --- /dev/null +++ b/superset-frontend/src/explore/components/RunQueryButton/index.tsx @@ -0,0 +1,56 @@ +/** + * 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 React, { ReactNode } from 'react'; +import { t } from '@superset-ui/core'; +import Button from 'src/components/Button'; + +export type RunQueryButtonProps = { + loading: boolean; + onQuery: () => void; + onStop: () => void; + errorMessage: ReactNode; + isNewChart: boolean; + canStopQuery: boolean; + chartIsStale: boolean; +}; + +export const RunQueryButton = ({ + loading, + onQuery, + onStop, + errorMessage, + isNewChart, + canStopQuery, + chartIsStale, +}: RunQueryButtonProps) => + loading ? ( + + ) : ( + + ); diff --git a/superset-frontend/src/explore/components/SaveModal.test.jsx b/superset-frontend/src/explore/components/SaveModal.test.jsx index b25efdb773a8b..b3a7ac3d58b17 100644 --- a/superset-frontend/src/explore/components/SaveModal.test.jsx +++ b/superset-frontend/src/explore/components/SaveModal.test.jsx @@ -95,9 +95,36 @@ describe('SaveModal', () => { expect(wrapper.find(Radio)).toHaveLength(2); const footerWrapper = shallow(wrapper.find(StyledModal).props().footer); + expect(footerWrapper.find(Button)).toHaveLength(3); }); + it('renders the right footer buttons when an existing dashboard', () => { + const wrapper = getWrapper(); + const footerWrapper = shallow(wrapper.find(StyledModal).props().footer); + const saveAndGoDash = footerWrapper + .find('#btn_modal_save_goto_dash') + .getElement(); + const save = footerWrapper.find('#btn_modal_save').getElement(); + expect(save.props.children).toBe('Save'); + expect(saveAndGoDash.props.children).toBe('Save & go to dashboard'); + }); + + it('renders the right footer buttons when a new dashboard', () => { + const wrapper = getWrapper(); + wrapper.setState({ + saveToDashboardId: null, + newDashboardName: 'Test new dashboard', + }); + const footerWrapper = shallow(wrapper.find(StyledModal).props().footer); + const saveAndGoDash = footerWrapper + .find('#btn_modal_save_goto_dash') + .getElement(); + const save = footerWrapper.find('#btn_modal_save').getElement(); + expect(save.props.children).toBe('Save to new dashboard'); + expect(saveAndGoDash.props.children).toBe('Save & go to new dashboard'); + }); + it('overwrite radio button is disabled for new slice', () => { const wrapper = getWrapper(); wrapper.setProps({ slice: null }); diff --git a/superset-frontend/src/explore/components/SaveModal.tsx b/superset-frontend/src/explore/components/SaveModal.tsx index 9c3e01eba072b..bf2ed48701393 100644 --- a/superset-frontend/src/explore/components/SaveModal.tsx +++ b/superset-frontend/src/explore/components/SaveModal.tsx @@ -76,6 +76,11 @@ class SaveModal extends React.Component { this.onSliceNameChange = this.onSliceNameChange.bind(this); this.changeAction = this.changeAction.bind(this); this.saveOrOverwrite = this.saveOrOverwrite.bind(this); + this.isNewDashboard = this.isNewDashboard.bind(this); + } + + isNewDashboard(): boolean { + return !!(!this.state.saveToDashboardId && this.state.newDashboardName); } canOverwriteSlice(): boolean { @@ -195,7 +200,9 @@ class SaveModal extends React.Component { } onClick={() => this.saveOrOverwrite(true)} > - {t('Save & go to dashboard')} + {this.isNewDashboard() + ? t('Save & go to new dashboard') + : t('Save & go to dashboard')}
diff --git a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.jsx b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.jsx index e12ca06cc943c..82c5fef2c2c9d 100644 --- a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.jsx +++ b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.jsx @@ -385,11 +385,13 @@ export default class AnnotationLayer extends React.PureComponent { description = 'Select the Annotation Layer you would like to use.'; } else { label = t('Chart'); - description = `Use a pre defined Superset Chart as a source for annotations and overlays. - your chart must be one of these visualization types: - [${this.getSupportedSourceTypes(annotationType) - .map(x => x.label) - .join(', ')}]`; + description = t( + `Use another existing chart as a source for annotations and overlays. + Your chart must be one of these visualization types: [%s]`, + this.getSupportedSourceTypes(annotationType) + .map(x => x.label) + .join(', '), + ); } } else if (annotationType === ANNOTATION_TYPES.FORMULA) { label = 'Formula'; diff --git a/superset-frontend/src/explore/components/controls/CheckboxControl.jsx b/superset-frontend/src/explore/components/controls/CheckboxControl.jsx index 5b14f0d52f772..a570bbfed6922 100644 --- a/superset-frontend/src/explore/components/controls/CheckboxControl.jsx +++ b/superset-frontend/src/explore/components/controls/CheckboxControl.jsx @@ -18,6 +18,7 @@ */ import React from 'react'; import PropTypes from 'prop-types'; +import { styled, css } from '@superset-ui/core'; import ControlHeader from '../ControlHeader'; import Checkbox from '../../../components/Checkbox'; @@ -32,7 +33,16 @@ const defaultProps = { onChange: () => {}, }; -const checkboxStyle = { paddingRight: '5px' }; +const CheckBoxControlWrapper = styled.div` + ${({ theme }) => css` + .ControlHeader label { + color: ${theme.colors.grayscale.dark1}; + } + span[role='checkbox'] { + padding-right: ${theme.gridUnit * 2}px; + } + `} +`; export default class CheckboxControl extends React.Component { onChange() { @@ -43,7 +53,6 @@ export default class CheckboxControl extends React.Component { return ( ); @@ -52,11 +61,13 @@ export default class CheckboxControl extends React.Component { render() { if (this.props.label) { return ( - + + + ); } return this.renderCheckbox(); diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx b/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx index f7cda8b752504..b011901eb0306 100644 --- a/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx +++ b/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx @@ -59,7 +59,8 @@ const Styles = styled.div` justify-content: space-between; align-items: center; border-bottom: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; - padding: ${({ theme }) => 2 * theme.gridUnit}px; + padding: ${({ theme }) => 4 * theme.gridUnit}px; + padding-right: ${({ theme }) => 2 * theme.gridUnit}px; } .error-alert { margin: ${({ theme }) => 2 * theme.gridUnit}px; diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx index c68ee009ea178..e7b25908cf11c 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx @@ -126,17 +126,7 @@ export function DndColumnSelect(props: DndColumnSelectProps) { [onChange, optionSelector], ); - const popoverOptions = useMemo( - () => - Object.values(options).filter( - col => - !optionSelector.values - .filter(isColumnMeta) - .map((val: ColumnMeta) => val.column_name) - .includes(col.column_name), - ), - [optionSelector.values, options], - ); + const popoverOptions = useMemo(() => Object.values(options), [options]); const valuesRenderer = useCallback( () => diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/index.tsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/index.tsx index 4c521d8aad451..58b1b25081f2a 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/index.tsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/index.tsx @@ -406,15 +406,11 @@ const AdhocFilterEditPopoverSimpleTabContent: React.FC = props => { {...operatorSelectProps} /> {MULTI_OPERATORS.has(operatorId) || suggestions.length > 0 ? ( - // We need to delay rendering the select because we can't pass a primitive value without options - // We can't pass value = [null] and options=[] - comparatorSelectProps.value && suggestions.length === 0 ? null : ( - - ) + ) : ( void; value: number; default?: number; }; -export default function SliderControl(props: SliderControlProps) { - const { onChange = () => {}, default: defaultValue, ...rest } = props; +export default function SliderControl({ + default: defaultValue, + name, + label, + description, + renderTrigger, + rightNode, + leftNode, + validationErrors, + hovered, + warning, + danger, + onClick, + tooltipOnClick, + onChange = () => {}, + ...rest +}: SliderControlProps) { + const headerProps = { + name, + label, + description, + renderTrigger, + rightNode, + leftNode, + validationErrors, + onClick, + hovered, + tooltipOnClick, + warning, + danger, + }; return ( <> - + ); diff --git a/superset-frontend/src/explore/constants.ts b/superset-frontend/src/explore/constants.ts index f9b58c553257e..f1353c7734bf7 100644 --- a/superset-frontend/src/explore/constants.ts +++ b/superset-frontend/src/explore/constants.ts @@ -32,13 +32,13 @@ export enum Operators { EQUALS = 'EQUALS', NOT_EQUALS = 'NOT_EQUALS', LESS_THAN = 'LESS_THAN', - GREATER_THAN = 'GREATER_THAN', LESS_THAN_OR_EQUAL = 'LESS_THAN_OR_EQUAL', + GREATER_THAN = 'GREATER_THAN', GREATER_THAN_OR_EQUAL = 'GREATER_THAN_OR_EQUAL', IN = 'IN', NOT_IN = 'NOT_IN', - ILIKE = 'ILIKE', LIKE = 'LIKE', + ILIKE = 'ILIKE', REGEX = 'REGEX', IS_NOT_NULL = 'IS_NOT_NULL', IS_NULL = 'IS_NULL', @@ -55,25 +55,31 @@ export interface OperatorType { export const OPERATOR_ENUM_TO_OPERATOR_TYPE: { [key in Operators]: OperatorType; } = { - [Operators.EQUALS]: { display: 'equals', operation: '==' }, - [Operators.NOT_EQUALS]: { display: 'not equals', operation: '!=' }, - [Operators.GREATER_THAN]: { display: '>', operation: '>' }, - [Operators.LESS_THAN]: { display: '<', operation: '<' }, - [Operators.GREATER_THAN_OR_EQUAL]: { display: '>=', operation: '>=' }, - [Operators.LESS_THAN_OR_EQUAL]: { display: '<=', operation: '<=' }, - [Operators.IN]: { display: 'IN', operation: 'IN' }, - [Operators.NOT_IN]: { display: 'NOT IN', operation: 'NOT IN' }, - [Operators.LIKE]: { display: 'LIKE', operation: 'LIKE' }, - [Operators.ILIKE]: { display: 'LIKE (case insensitive)', operation: 'ILIKE' }, - [Operators.REGEX]: { display: 'REGEX', operation: 'REGEX' }, - [Operators.IS_NOT_NULL]: { display: 'IS NOT NULL', operation: 'IS NOT NULL' }, - [Operators.IS_NULL]: { display: 'IS NULL', operation: 'IS NULL' }, + [Operators.EQUALS]: { display: 'Equal to (=)', operation: '==' }, + [Operators.NOT_EQUALS]: { display: 'Not equal to (≠)', operation: '!=' }, + [Operators.LESS_THAN]: { display: 'Less than (<)', operation: '<' }, + [Operators.LESS_THAN_OR_EQUAL]: { + display: 'Less or equal (<=)', + operation: '<=', + }, + [Operators.GREATER_THAN]: { display: 'Greater than (>)', operation: '>' }, + [Operators.GREATER_THAN_OR_EQUAL]: { + display: 'Greater or equal (>=)', + operation: '>=', + }, + [Operators.IN]: { display: 'In', operation: 'IN' }, + [Operators.NOT_IN]: { display: 'Not in', operation: 'NOT IN' }, + [Operators.LIKE]: { display: 'Like', operation: 'LIKE' }, + [Operators.ILIKE]: { display: 'Like (case insensitive)', operation: 'ILIKE' }, + [Operators.REGEX]: { display: 'Regex', operation: 'REGEX' }, + [Operators.IS_NOT_NULL]: { display: 'Is not null', operation: 'IS NOT NULL' }, + [Operators.IS_NULL]: { display: 'Is null', operation: 'IS NULL' }, [Operators.LATEST_PARTITION]: { display: 'use latest_partition template', operation: 'LATEST PARTITION', }, - [Operators.IS_TRUE]: { display: 'IS TRUE', operation: '==' }, - [Operators.IS_FALSE]: { display: 'IS FALSE', operation: '==' }, + [Operators.IS_TRUE]: { display: 'Is true', operation: '==' }, + [Operators.IS_FALSE]: { display: 'Is false', operation: '==' }, }; export const OPERATORS_OPTIONS = Object.values(Operators) as Operators[]; @@ -83,10 +89,10 @@ export const DRUID_ONLY_OPERATORS = [Operators.REGEX]; export const HAVING_OPERATORS = [ Operators.EQUALS, Operators.NOT_EQUALS, - Operators.GREATER_THAN, Operators.LESS_THAN, - Operators.GREATER_THAN_OR_EQUAL, Operators.LESS_THAN_OR_EQUAL, + Operators.GREATER_THAN, + Operators.GREATER_THAN_OR_EQUAL, ]; export const MULTI_OPERATORS = new Set([Operators.IN, Operators.NOT_IN]); // CUSTOM_OPERATORS will show operator in simple mode, diff --git a/superset-frontend/src/explore/controlPanels/sections.tsx b/superset-frontend/src/explore/controlPanels/sections.tsx index a1c786a73c15d..a6adbf3af23c3 100644 --- a/superset-frontend/src/explore/controlPanels/sections.tsx +++ b/superset-frontend/src/explore/controlPanels/sections.tsx @@ -132,7 +132,7 @@ export const NVD3TimeSeries: ControlPanelSectionConfig[] = [ 'of query results', ), controlSetRows: [ - [

{t('Rolling window')}

], + [
{t('Rolling window')}
], [ { name: 'rolling_type', @@ -181,7 +181,7 @@ export const NVD3TimeSeries: ControlPanelSectionConfig[] = [ }, }, ], - [

{t('Time comparison')}

], + [
{t('Time comparison')}
], [ { name: 'time_compare', @@ -230,9 +230,7 @@ export const NVD3TimeSeries: ControlPanelSectionConfig[] = [ }, }, ], - [

{t('Python functions')}

], - // eslint-disable-next-line jsx-a11y/heading-has-content - [

pandas.resample

], + [
{t('Resample')}
], [ { name: 'resample_rule', diff --git a/superset-frontend/src/explore/controlUtils/controlUtils.test.tsx b/superset-frontend/src/explore/controlUtils/controlUtils.test.tsx index ac8dcef7a517e..1b9cf1ea55c73 100644 --- a/superset-frontend/src/explore/controlUtils/controlUtils.test.tsx +++ b/superset-frontend/src/explore/controlUtils/controlUtils.test.tsx @@ -16,12 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import { getChartControlPanelRegistry, t } from '@superset-ui/core'; +import { + DatasourceType, + getChartControlPanelRegistry, + t, +} from '@superset-ui/core'; import { ControlConfig, ControlPanelState, CustomControlItem, - DatasourceMeta, } from '@superset-ui/chart-controls'; import { getControlConfig, @@ -44,9 +47,16 @@ const getKnownControlState = (...args: Parameters) => describe('controlUtils', () => { const state: ControlPanelState = { datasource: { + id: 1, + type: DatasourceType.Table, columns: [{ column_name: 'a' }], metrics: [{ metric_name: 'first' }, { metric_name: 'second' }], - } as unknown as DatasourceMeta, + column_format: {}, + verbose_map: {}, + main_dttm_col: '', + datasource_name: '1__table', + description: null, + }, controls: {}, form_data: { datasource: '1__table', viz_type: 'table' }, }; diff --git a/superset-frontend/src/explore/controlUtils/getFormDataFromControls.ts b/superset-frontend/src/explore/controlUtils/getFormDataFromControls.ts index f5ffa523c359e..ba9419da18737 100644 --- a/superset-frontend/src/explore/controlUtils/getFormDataFromControls.ts +++ b/superset-frontend/src/explore/controlUtils/getFormDataFromControls.ts @@ -22,13 +22,10 @@ import { ControlStateMapping } from '@superset-ui/chart-controls'; export function getFormDataFromControls( controlsState: ControlStateMapping, ): QueryFormData { - const formData: QueryFormData = { - viz_type: 'table', - datasource: '', - }; + const formData = {}; Object.keys(controlsState).forEach(controlName => { const control = controlsState[controlName]; formData[controlName] = control.value; }); - return formData; + return formData as QueryFormData; } diff --git a/superset-frontend/src/explore/controls.jsx b/superset-frontend/src/explore/controls.jsx index 974a79b9af7d8..daba78ca6d243 100644 --- a/superset-frontend/src/explore/controls.jsx +++ b/superset-frontend/src/explore/controls.jsx @@ -120,7 +120,7 @@ const groupByControl = { type: 'SelectControl', multi: true, freeForm: true, - label: t('Group by'), + label: t('Dimensions'), default: [], includeTime: false, description: t( @@ -393,7 +393,7 @@ export const controls = { series: { ...groupByControl, - label: t('Series'), + label: t('Dimensions'), multi: false, default: null, description: t( diff --git a/superset-frontend/src/explore/exploreUtils/index.js b/superset-frontend/src/explore/exploreUtils/index.js index 679902243b4df..79bcb1a36241e 100644 --- a/superset-frontend/src/explore/exploreUtils/index.js +++ b/superset-frontend/src/explore/exploreUtils/index.js @@ -26,6 +26,7 @@ import { getChartBuildQueryRegistry, getChartMetadataRegistry, } from '@superset-ui/core'; +import { omit } from 'lodash'; import { availableDomains } from 'src/utils/hostNamesConfig'; import { safeStringify } from 'src/utils/safeStringify'; import { URL_PARAMS } from 'src/constants'; @@ -215,7 +216,7 @@ export const buildV1ChartDataPayload = ({ ...baseQueryObject, }, ])); - return buildQuery( + const payload = buildQuery( { ...formData, force, @@ -229,6 +230,13 @@ export const buildV1ChartDataPayload = ({ }, }, ); + if (resultType === 'samples') { + // remove row limit and offset to fall back to defaults + payload.queries = payload.queries.map(query => + omit(query, ['row_limit', 'row_offset']), + ); + } + return payload; }; export const getLegacyEndpointType = ({ resultType, resultFormat }) => diff --git a/superset-frontend/src/explore/main.less b/superset-frontend/src/explore/main.less index d85e855b4d2cc..015a8a1a3bed3 100644 --- a/superset-frontend/src/explore/main.less +++ b/superset-frontend/src/explore/main.less @@ -127,18 +127,11 @@ } } -h1.section-header { - font-size: @font-size-m; - font-weight: @font-weight-bold; - margin-bottom: 0; - margin-top: 0; - padding-bottom: 5px; -} - -h2.section-header { +div.section-header { font-size: @font-size-s; font-weight: @font-weight-bold; + color: @gray-light5; margin-bottom: 0; margin-top: 0; - padding-bottom: 5px; + padding-bottom: 16px; } diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx index 8388e13a3d594..9803e4a7e6fac 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx @@ -306,7 +306,9 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { value={filterState.value || []} disabled={isDisabled} getPopupContainer={ - showOverflow ? () => parentRef?.current : undefined + showOverflow + ? () => parentRef?.current + : (trigger: HTMLElement) => trigger?.parentNode } showSearch={showSearch} mode={multiSelect ? 'multiple' : 'single'} diff --git a/superset-frontend/src/profile/App.tsx b/superset-frontend/src/profile/App.tsx index 6f935ee710f99..1f2cd144afcc1 100644 --- a/superset-frontend/src/profile/App.tsx +++ b/superset-frontend/src/profile/App.tsx @@ -27,7 +27,6 @@ import App from 'src/profile/components/App'; import messageToastReducer from 'src/components/MessageToasts/reducers'; import { initEnhancer } from 'src/reduxUtils'; import setupApp from 'src/setup/setupApp'; -import './main.less'; import { theme } from 'src/preamble'; import ToastContainer from 'src/components/MessageToasts/ToastContainer'; diff --git a/superset-frontend/src/profile/components/CreatedContent.tsx b/superset-frontend/src/profile/components/CreatedContent.tsx index b097dee5e1448..61dfb418f09c3 100644 --- a/superset-frontend/src/profile/components/CreatedContent.tsx +++ b/superset-frontend/src/profile/components/CreatedContent.tsx @@ -16,13 +16,14 @@ * specific language governing permissions and limitations * under the License. */ +import rison from 'rison'; import React from 'react'; import moment from 'moment'; import { t } from '@superset-ui/core'; import TableLoader from '../../components/TableLoader'; import { Slice } from '../types'; -import { User, Dashboard } from '../../types/bootstrapTypes'; +import { User, DashboardResponse } from '../../types/bootstrapTypes'; interface CreatedContentProps { user: User; @@ -49,17 +50,27 @@ class CreatedContent extends React.PureComponent { } renderDashboardTable() { - const mutator = (data: Dashboard[]) => - data.map(dash => ({ - dashboard: {dash.title}, - created: moment.utc(dash.dttm).fromNow(), - _created: dash.dttm, + const search = [{ col: 'created_by', opr: 'created_by_me', value: 'me' }]; + const query = rison.encode({ + keys: ['none'], + columns: ['created_on_delta_humanized', 'dashboard_title', 'url'], + filters: search, + order_column: 'changed_on', + order_direction: 'desc', + page: 0, + page_size: 100, + }); + const mutator = (data: DashboardResponse) => + data.result.map(dash => ({ + dashboard: {dash.dashboard_title}, + created: dash.created_on_delta_humanized, + _created: dash.created_on_delta_humanized, })); return ( ` + padding-top: 0; + padding-right: ${theme.gridUnit * 2 + 2}px; + padding-bottom: ${theme.gridUnit * 5}px; + padding-left: ${theme.gridUnit / 2}px; +`} +`; + if (scheduleInfo && config) { // hide instructions when showing schedule info config.JSONSCHEMA.description = ''; ReactDom.render( -
+
{linkback && ( - + )} -
, + , scheduleInfoContainer, ); } diff --git a/superset-frontend/src/showSavedQuery/index.less b/superset-frontend/src/showSavedQuery/index.less deleted file mode 100644 index 7008bec9ea8a3..0000000000000 --- a/superset-frontend/src/showSavedQuery/index.less +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -.btn-add { - display: none; -} - -.linkback { - padding: 0 10px 20px 2px; -} diff --git a/superset-frontend/src/types/bootstrapTypes.ts b/superset-frontend/src/types/bootstrapTypes.ts index dc41eb5878f81..8918ea8489046 100644 --- a/superset-frontend/src/types/bootstrapTypes.ts +++ b/superset-frontend/src/types/bootstrapTypes.ts @@ -1,4 +1,5 @@ import { JsonObject, Locale } from '@superset-ui/core'; +import { isPlainObject } from 'lodash'; /** * Licensed to the Apache Software Foundation (ASF) under one @@ -37,6 +38,8 @@ export interface UserWithPermissionsAndRoles extends User { roles: Record; } +export type UndefinedUser = {}; + export type Dashboard = { dttm: number; id: number; @@ -46,9 +49,29 @@ export type Dashboard = { creator_url?: string; }; +export type DashboardData = { + dashboard_title?: string; + created_on_delta_humanized?: string; + url: string; +}; + +export type DashboardResponse = { + result: DashboardData[]; +}; + export interface CommonBootstrapData { flash_messages: string[][]; conf: JsonObject; locale: Locale; feature_flags: Record; } + +export function isUser(user: any): user is User { + return isPlainObject(user) && 'username' in user; +} + +export function isUserWithPermissionsAndRoles( + user: any, +): user is UserWithPermissionsAndRoles { + return isUser(user) && 'permissions' in user && 'roles' in user; +} diff --git a/superset-frontend/src/profile/main.less b/superset-frontend/src/utils/getChartRequiredFieldsMissingMessage.ts similarity index 73% rename from superset-frontend/src/profile/main.less rename to superset-frontend/src/utils/getChartRequiredFieldsMissingMessage.ts index 7e16c67499745..ac11e8503dc2f 100644 --- a/superset-frontend/src/profile/main.less +++ b/superset-frontend/src/utils/getChartRequiredFieldsMissingMessage.ts @@ -16,17 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -@import '../assets/stylesheets/less/variables.less'; -.tab-pane { - min-height: 400px; - background: @lightest; - border: 1px solid @gray-light; - border-top: 0px; -} +import { t } from '@superset-ui/core'; -.label { - display: inline-block; - margin-right: 5px; - margin-bottom: 5px; -} +export const getChartRequiredFieldsMissingMessage = (isCreating: boolean) => + t( + 'Select values in highlighted field(s) in the control panel. Then run the query by clicking on the %s button.', + isCreating ? '"Create chart"' : '"Update chart"', + ); diff --git a/superset-frontend/src/utils/urlUtils.ts b/superset-frontend/src/utils/urlUtils.ts index be857517e06d0..bd570291f2cba 100644 --- a/superset-frontend/src/utils/urlUtils.ts +++ b/superset-frontend/src/utils/urlUtils.ts @@ -154,11 +154,15 @@ export function getChartPermalink( }); } -export function getDashboardPermalink( - dashboardId: string, - filterState: JsonObject, - hash?: string, -) { +export function getDashboardPermalink({ + dashboardId, + filterState, + hash, // the anchor part of the link which corresponds to the tab/chart id +}: { + dashboardId: string | number; + filterState: JsonObject; + hash?: string; +}) { // only encode filter box state if non-empty return getPermalink(`/api/v1/dashboard/${dashboardId}/permalink`, { filterState, diff --git a/superset-frontend/src/views/CRUD/alert/AlertList.tsx b/superset-frontend/src/views/CRUD/alert/AlertList.tsx index 2d84cb0b976df..66fc0109238a5 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertList.tsx +++ b/superset-frontend/src/views/CRUD/alert/AlertList.tsx @@ -22,7 +22,6 @@ import { useHistory } from 'react-router-dom'; import { t, SupersetClient, makeApi, styled } from '@superset-ui/core'; import moment from 'moment'; import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; -import Button from 'src/components/Button'; import FacePile from 'src/components/FacePile'; import { Tooltip } from 'src/components/Tooltip'; import ListView, { @@ -90,7 +89,7 @@ function AlertList({ const title = isReportEnabled ? t('report') : t('alert'); const titlePlural = isReportEnabled ? t('reports') : t('alerts'); const pathName = isReportEnabled ? 'Reports' : 'Alerts'; - const initalFilters = useMemo( + const initialFilters = useMemo( () => [ { id: 'type', @@ -118,7 +117,7 @@ function AlertList({ addDangerToast, true, undefined, - initalFilters, + initialFilters, ); const { updateResource } = useSingleViewResource>( @@ -262,9 +261,15 @@ function AlertList({ size: 'xl', }, { - accessor: 'created_by', + Cell: ({ + row: { + original: { created_by }, + }, + }: any) => + created_by ? `${created_by.first_name} ${created_by.last_name}` : '', + Header: t('Created by'), + id: 'created_by', disableSortBy: true, - hidden: true, size: 'xl', }, { @@ -366,19 +371,35 @@ function AlertList({ }); } - const EmptyStateButton = ( - - ); - const emptyState = { - message: t('No %s yet', titlePlural), - slot: canCreate ? EmptyStateButton : null, + title: t('No %s yet', titlePlural), + image: 'filter-results.svg', + buttonAction: () => handleAlertEdit(null), + buttonText: canCreate ? ( + <> + {title}{' '} + + ) : null, }; const filters: Filters = useMemo( () => [ + { + Header: t('Owner'), + id: 'owners', + input: 'select', + operator: FilterOperator.relationManyMany, + unfilteredLabel: 'All', + fetchSelects: createFetchRelated( + 'report', + 'owners', + createErrorHandler(errMsg => + t('An error occurred while fetching owners values: %s', errMsg), + ), + user, + ), + paginate: true, + }, { Header: t('Created by'), id: 'created_by', diff --git a/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx index 6323edf3a0ca3..04398a3582fc9 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx +++ b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx @@ -73,6 +73,7 @@ type SelectValue = { }; interface AlertReportModalProps { + addSuccessToast: (msg: string) => void; addDangerToast: (msg: string) => void; alert?: AlertObject | null; isReport?: boolean; @@ -153,6 +154,9 @@ const DEFAULT_ALERT = { }; const StyledModal = styled(Modal)` + max-width: 1200px; + width: 100%; + .ant-modal-body { overflow: initial; } @@ -165,7 +169,6 @@ const StyledIcon = (theme: SupersetTheme) => css` const StyledSectionContainer = styled.div` display: flex; - min-width: 1000px; flex-direction: column; .header-section { @@ -260,7 +263,7 @@ export const StyledInputContainer = styled.div` .helper { display: block; color: ${({ theme }) => theme.colors.grayscale.base}; - font-size: ${({ theme }) => theme.typography.sizes.s - 1}px; + font-size: ${({ theme }) => theme.typography.sizes.s}px; padding: ${({ theme }) => theme.gridUnit}px 0; text-align: left; } @@ -402,6 +405,7 @@ const AlertReportModal: FunctionComponent = ({ show, alert = null, isReport = false, + addSuccessToast, }) => { const conf = useCommonConf(); const allowedNotificationMethods: NotificationMethodOption[] = @@ -555,6 +559,8 @@ const AlertReportModal: FunctionComponent = ({ return; } + addSuccessToast(t('%s updated', data.type)); + if (onAdd) { onAdd(); } @@ -569,6 +575,8 @@ const AlertReportModal: FunctionComponent = ({ return; } + addSuccessToast(t('%s updated', data.type)); + if (onAdd) { onAdd(response); } @@ -1242,6 +1250,7 @@ const AlertReportModal: FunctionComponent = ({
diff --git a/superset-frontend/src/views/CRUD/alert/ExecutionLog.tsx b/superset-frontend/src/views/CRUD/alert/ExecutionLog.tsx index c424a931b3c17..8bdae92eb53d0 100644 --- a/superset-frontend/src/views/CRUD/alert/ExecutionLog.tsx +++ b/superset-frontend/src/views/CRUD/alert/ExecutionLog.tsx @@ -22,6 +22,7 @@ import moment from 'moment'; import React, { useEffect, useMemo } from 'react'; import { Link, useParams } from 'react-router-dom'; import ListView from 'src/components/ListView'; +import { Tooltip } from 'src/components/Tooltip'; import SubMenu from 'src/views/components/SubMenu'; import withToasts from 'src/components/MessageToasts/withToasts'; import { fDuration } from 'src/modules/dates'; @@ -144,6 +145,15 @@ function ExecutionLog({ addDangerToast, isReportEnabled }: ExecutionLogProps) { { accessor: 'error_message', Header: t('Error message'), + Cell: ({ + row: { + original: { error_message = '' }, + }, + }: any) => ( + + {error_message} + + ), }, ], [isReportEnabled], diff --git a/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.test.tsx b/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.test.tsx index 822b129c56de7..5d36c2994dcab 100644 --- a/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.test.tsx +++ b/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.test.tsx @@ -16,58 +16,138 @@ * specific language governing permissions and limitations * under the License. */ - import React from 'react'; -import { ReactWrapper } from 'enzyme'; -import { styledMount as mount } from 'spec/helpers/theming'; -import { CronPicker } from 'src/components/CronPicker'; -import { Input } from 'src/components/Input'; -import { AlertReportCronScheduler } from './AlertReportCronScheduler'; +import { render, screen, waitFor, within } from 'spec/helpers/testing-library'; +import userEvent from '@testing-library/user-event'; +import { act } from 'react-dom/test-utils'; + +import { + AlertReportCronScheduler, + AlertReportCronSchedulerProps, +} from './AlertReportCronScheduler'; + +const createProps = (props: Partial = {}) => ({ + onChange: jest.fn(), + value: '* * * * *', + ...props, +}); + +test('should render', () => { + const props = createProps(); + render(); + + // Text found in the first radio option + expect(screen.getByText('Every')).toBeInTheDocument(); + // Text found in the second radio option + expect(screen.getByText('CRON Schedule')).toBeInTheDocument(); +}); + +test('only one radio option should be enabled at a time', () => { + const props = createProps(); + const { container } = render(); + + expect(screen.getByTestId('picker')).toBeChecked(); + expect(screen.getByTestId('input')).not.toBeChecked(); + + const pickerContainer = container.querySelector( + '.react-js-cron-select', + ) as HTMLElement; + const inputContainer = screen.getByTestId('input-content'); + + expect(within(pickerContainer).getAllByRole('combobox')[0]).toBeEnabled(); + expect(inputContainer.querySelector('input[name="crontab"]')).toBeDisabled(); + + userEvent.click(screen.getByTestId('input')); + + expect(within(pickerContainer).getAllByRole('combobox')[0]).toBeDisabled(); + expect(inputContainer.querySelector('input[name="crontab"]')).toBeEnabled(); + + userEvent.click(screen.getByTestId('picker')); + + expect(within(pickerContainer).getAllByRole('combobox')[0]).toBeEnabled(); + expect(inputContainer.querySelector('input[name="crontab"]')).toBeDisabled(); +}); + +test('picker mode updates correctly', async () => { + const onChangeCallback = jest.fn(); + const props = createProps({ + onChange: onChangeCallback, + }); + + const { container } = render(); -describe('AlertReportCronScheduler', () => { - let wrapper: ReactWrapper; + expect(screen.getByTestId('picker')).toBeChecked(); - it('calls onChnage when value chnages', () => { - const onChangeMock = jest.fn(); - wrapper = mount( - , - ); + const pickerContainer = container.querySelector( + '.react-js-cron-select', + ) as HTMLElement; - const changeValue = '1,7 * * * *'; + const firstSelect = within(pickerContainer).getAllByRole('combobox')[0]; + act(() => { + userEvent.click(firstSelect); + }); - wrapper.find(CronPicker).props().setValue(changeValue); - expect(onChangeMock).toHaveBeenLastCalledWith(changeValue); + expect(await within(pickerContainer).findByText('day')).toBeInTheDocument(); + act(() => { + userEvent.click(within(pickerContainer).getByText('day')); }); - it.skip('sets input value when cron picker changes', () => { - const onChangeMock = jest.fn(); - wrapper = mount( - , - ); + expect(onChangeCallback).toHaveBeenLastCalledWith('* * * * *'); + + const secondSelect = container.querySelector( + '.react-js-cron-hours .ant-select-selector', + ) as HTMLElement; + await waitFor(() => { + expect(secondSelect).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(secondSelect); + }); - const changeValue = '1,7 * * * *'; + expect(await screen.findByText('9')).toBeInTheDocument(); + act(() => { + userEvent.click(screen.getByText('9')); + }); - wrapper.find(CronPicker).props().setValue(changeValue); - // TODO fix this class-style assertion that doesn't work on function components - // @ts-ignore - expect(wrapper.find(Input).state().value).toEqual(changeValue); + await waitFor(() => { + expect(onChangeCallback).toHaveBeenLastCalledWith('* 9 * * *'); }); +}); - it('calls onChange when input value changes', () => { - const onChangeMock = jest.fn(); - wrapper = mount( - , - ); - - const changeValue = '1,7 * * * *'; - const event = { - target: { value: changeValue }, - } as React.FocusEvent; - - const inputProps = wrapper.find(Input).props(); - if (inputProps.onBlur) { - inputProps.onBlur(event); - } - expect(onChangeMock).toHaveBeenLastCalledWith(changeValue); +test('input mode updates correctly', async () => { + const onChangeCallback = jest.fn(); + const props = createProps({ + onChange: onChangeCallback, }); + + render(); + + const inputContainer = screen.getByTestId('input-content'); + userEvent.click(screen.getByTestId('input')); + + const input = inputContainer.querySelector( + 'input[name="crontab"]', + ) as HTMLElement; + await waitFor(() => { + expect(input).toBeEnabled(); + }); + + userEvent.clear(input); + expect(input).toHaveValue(''); + + const value = '* 10 2 * *'; + await act(async () => { + await userEvent.type(input, value, { delay: 1 }); + }); + + await waitFor(() => { + expect(input).toHaveValue(value); + }); + + act(() => { + userEvent.click(inputContainer); + }); + + expect(onChangeCallback).toHaveBeenLastCalledWith(value); }); diff --git a/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.tsx b/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.tsx index 867ee880d7d73..5418842aeaaa5 100644 --- a/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.tsx +++ b/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.tsx @@ -16,27 +16,33 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useState, useCallback, useRef, FunctionComponent } from 'react'; +import React, { useState, useCallback, useRef, FocusEvent } from 'react'; import { t, useTheme } from '@superset-ui/core'; -import { AntdInput } from 'src/components'; +import { AntdInput, RadioChangeEvent } from 'src/components'; import { Input } from 'src/components/Input'; import { Radio } from 'src/components/Radio'; import { CronPicker, CronError } from 'src/components/CronPicker'; import { StyledInputContainer } from 'src/views/CRUD/alert/AlertReportModal'; -interface AlertReportCronSchedulerProps { +export interface AlertReportCronSchedulerProps { value: string; onChange: (change: string) => any; } -export const AlertReportCronScheduler: FunctionComponent = +export const AlertReportCronScheduler: React.FC = ({ value, onChange }) => { const theme = useTheme(); const inputRef = useRef(null); const [scheduleFormat, setScheduleFormat] = useState<'picker' | 'input'>( 'picker', ); + + const handleRadioButtonChange = useCallback( + (e: RadioChangeEvent) => setScheduleFormat(e.target.value), + [], + ); + const customSetValue = useCallback( (newValue: string) => { onChange(newValue); @@ -44,16 +50,25 @@ export const AlertReportCronScheduler: FunctionComponent) => { + onChange(event.target.value); + }, + [onChange], + ); + + const handlePressEnter = useCallback(() => { + onChange(inputRef.current?.input.value || ''); + }, [onChange]); + const [error, onError] = useState(); return ( <> - setScheduleFormat(e.target.value)} - value={scheduleFormat} - > +
- +
- + CRON Schedule - +
{ - onChange(event.target.value); - }} - onPressEnter={() => { - onChange(inputRef.current?.input.value || ''); - }} + onBlur={handleBlur} + onPressEnter={handlePressEnter} />
diff --git a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx b/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx index 413373aefce68..a4599b9ff5dfb 100644 --- a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx +++ b/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx @@ -24,7 +24,6 @@ import moment from 'moment'; import rison from 'rison'; import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; -import Button from 'src/components/Button'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import DeleteModal from 'src/components/DeleteModal'; import ListView, { ListViewProps } from 'src/components/ListView'; @@ -239,22 +238,17 @@ function AnnotationList({ hasHistory = false; } - const EmptyStateButton = ( - - ); - - const emptyState = { - message: t('No annotation yet'), - slot: EmptyStateButton, + ), }; return ( @@ -262,7 +256,7 @@ function AnnotationList({ - {t(`Annotation Layer ${annotationLayerName}`)} + {t('Annotation Layer %s', annotationLayerName)} {hasHistory ? ( Back to all diff --git a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx index b93e31d38017b..0265682dc7e6c 100644 --- a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx +++ b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx @@ -32,7 +32,6 @@ import ListView, { Filters, FilterOperator, } from 'src/components/ListView'; -import Button from 'src/components/Button'; import DeleteModal from 'src/components/DeleteModal'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import AnnotationLayerModal from './AnnotationLayerModal'; @@ -311,22 +310,15 @@ function AnnotationLayersList({ [], ); - const EmptyStateButton = ( - - ); - - const emptyState = { - message: t('No annotation layers yet'), - slot: EmptyStateButton, + ), }; const onLayerAdd = (id?: number) => { diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardCard.tsx b/superset-frontend/src/views/CRUD/dashboard/DashboardCard.tsx index 25a012f86b32b..2da9f45515336 100644 --- a/superset-frontend/src/views/CRUD/dashboard/DashboardCard.tsx +++ b/superset-frontend/src/views/CRUD/dashboard/DashboardCard.tsx @@ -44,7 +44,7 @@ interface DashboardCardProps { saveFavoriteStatus: (id: number, isStarred: boolean) => void; favoriteStatus: boolean; dashboardFilter?: string; - userId?: number; + userId?: string | number; showThumbnails?: boolean; handleBulkDashboardExport: (dashboardsToExport: Dashboard[]) => void; } @@ -171,11 +171,13 @@ function DashboardCard({ e.preventDefault(); }} > - + {userId && ( + + )} diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardList.test.jsx b/superset-frontend/src/views/CRUD/dashboard/DashboardList.test.jsx index 3561cafdbce6c..e42ba92ff2926 100644 --- a/superset-frontend/src/views/CRUD/dashboard/DashboardList.test.jsx +++ b/superset-frontend/src/views/CRUD/dashboard/DashboardList.test.jsx @@ -36,6 +36,9 @@ import DashboardList from 'src/views/CRUD/dashboard/DashboardList'; import ListView from 'src/components/ListView'; import ListViewCard from 'src/components/ListViewCard'; import PropertiesModal from 'src/dashboard/components/PropertiesModal'; +import FaveStar from 'src/components/FaveStar'; +import TableCollection from 'src/components/TableCollection'; +import CardCollection from 'src/components/ListView/CardCollection'; // store needed for withToasts(DashboardTable) const mockStore = configureStore([thunk]); @@ -104,15 +107,18 @@ describe('DashboardList', () => { }); const mockedProps = {}; - const wrapper = mount( - - - - - , - ); + let wrapper; beforeAll(async () => { + fetchMock.resetHistory(); + wrapper = mount( + + + + + , + ); + await waitForComponentToPaint(wrapper); }); @@ -178,6 +184,18 @@ describe('DashboardList', () => { await waitForComponentToPaint(wrapper); expect(wrapper.find(ConfirmStatusChange)).toExist(); }); + + it('renders the Favorite Star column in list view for logged in user', async () => { + wrapper.find('[aria-label="list-view"]').first().simulate('click'); + await waitForComponentToPaint(wrapper); + expect(wrapper.find(TableCollection).find(FaveStar)).toExist(); + }); + + it('renders the Favorite Star in card view for logged in user', async () => { + wrapper.find('[aria-label="card-view"]').first().simulate('click'); + await waitForComponentToPaint(wrapper); + expect(wrapper.find(CardCollection).find(FaveStar)).toExist(); + }); }); describe('RTL', () => { @@ -222,3 +240,39 @@ describe('RTL', () => { expect(importTooltip).toBeInTheDocument(); }); }); + +describe('DashboardList - anonymous view', () => { + const mockedProps = {}; + const mockUserLoggedOut = {}; + let wrapper; + + beforeAll(async () => { + fetchMock.resetHistory(); + wrapper = mount( + + + + + , + ); + + await waitForComponentToPaint(wrapper); + }); + + afterAll(() => { + cleanup(); + fetch.resetMocks(); + }); + + it('does not render the Favorite Star column in list view for anonymous user', async () => { + wrapper.find('[aria-label="list-view"]').first().simulate('click'); + await waitForComponentToPaint(wrapper); + expect(wrapper.find(TableCollection).find(FaveStar)).not.toExist(); + }); + + it('does not render the Favorite Star in card view for anonymous user', async () => { + wrapper.find('[aria-label="card-view"]').first().simulate('click'); + await waitForComponentToPaint(wrapper); + expect(wrapper.find(CardCollection).find(FaveStar)).not.toExist(); + }); +}); diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx index b3da8ee8e3534..a00ceefbc8761 100644 --- a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx +++ b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx @@ -17,7 +17,7 @@ * under the License. */ import { styled, SupersetClient, t } from '@superset-ui/core'; -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { Link } from 'react-router-dom'; import rison from 'rison'; import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; @@ -95,7 +95,11 @@ const Actions = styled.div` `; function DashboardList(props: DashboardListProps) { - const { addDangerToast, addSuccessToast } = props; + const { + addDangerToast, + addSuccessToast, + user: { userId }, + } = props; const { state: { @@ -143,7 +147,6 @@ function DashboardList(props: DashboardListProps) { addSuccessToast(t('Dashboard imported')); }; - const { userId } = props.user; // TODO: Fix usage of localStorage keying on the user id const userKey = dangerouslyGetItemDoNotUse(userId?.toString(), null); @@ -232,27 +235,25 @@ function DashboardList(props: DashboardListProps) { const columns = useMemo( () => [ - ...(props.user.userId - ? [ - { - Cell: ({ - row: { - original: { id }, - }, - }: any) => ( - - ), - Header: '', - id: 'id', - disableSortBy: true, - size: 'xs', - }, - ] - : []), + { + Cell: ({ + row: { + original: { id }, + }, + }: any) => + userId && ( + + ), + Header: '', + id: 'id', + disableSortBy: true, + size: 'xs', + hidden: !userId, + }, { Cell: ({ row: { @@ -422,10 +423,15 @@ function DashboardList(props: DashboardListProps) { }, ], [ + userId, canEdit, canDelete, canExport, - ...(props.user.userId ? [favoriteStatus] : []), + saveFavoriteStatus, + favoriteStatus, + refreshData, + addSuccessToast, + addDangerToast, ], ); @@ -500,7 +506,7 @@ function DashboardList(props: DashboardListProps) { { label: t('Draft'), value: false }, ], }, - ...(props.user.userId ? [favoritesFilter] : []), + ...(userId ? [favoritesFilter] : []), { Header: t('Certified'), id: 'id', @@ -544,8 +550,8 @@ function DashboardList(props: DashboardListProps) { }, ]; - function renderCard(dashboard: Dashboard) { - return ( + const renderCard = useCallback( + (dashboard: Dashboard) => ( - ); - } + ), + [ + addDangerToast, + addSuccessToast, + bulkSelectEnabled, + favoriteStatus, + hasPerm, + loading, + userId, + refreshData, + saveFavoriteStatus, + userKey, + ], + ); const subMenuButtons: SubMenuProps['buttons'] = []; if (canDelete || canExport) { diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx index 12580d8ee73fa..5fe6ead7fdf08 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx @@ -18,15 +18,11 @@ */ import React from 'react'; import thunk from 'redux-thunk'; -import * as redux from 'react-redux'; +import * as reactRedux from 'react-redux'; import configureStore from 'redux-mock-store'; import fetchMock from 'fetch-mock'; import { Provider } from 'react-redux'; import { styledMount as mount } from 'spec/helpers/theming'; -import { render, screen, cleanup } from 'spec/helpers/testing-library'; -import userEvent from '@testing-library/user-event'; -import { QueryParamProvider } from 'use-query-params'; -import * as featureFlags from 'src/featureFlags'; import DatabaseList from 'src/views/CRUD/data/database/DatabaseList'; import DatabaseModal from 'src/views/CRUD/data/database/DatabaseModal'; @@ -38,20 +34,10 @@ import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; import { act } from 'react-dom/test-utils'; // store needed for withToasts(DatabaseList) + const mockStore = configureStore([thunk]); const store = mockStore({}); -const mockAppState = { - common: { - config: { - CSV_EXTENSIONS: ['csv'], - EXCEL_EXTENSIONS: ['xls', 'xlsx'], - COLUMNAR_EXTENSIONS: ['parquet', 'zip'], - ALLOWED_EXTENSIONS: ['parquet', 'zip', 'xls', 'xlsx', 'csv'], - }, - }, -}; - const databasesInfoEndpoint = 'glob:*/api/v1/database/_info*'; const databasesEndpoint = 'glob:*/api/v1/database/?*'; const databaseEndpoint = 'glob:*/api/v1/database/*'; @@ -78,10 +64,6 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); -const mockUser = { - userId: 1, -}; - fetchMock.get(databasesInfoEndpoint, { permissions: ['can_write'], }); @@ -106,7 +88,13 @@ fetchMock.get(databaseRelatedEndpoint, { }, }); -const useSelectorMock = jest.spyOn(redux, 'useSelector'); +fetchMock.get( + 'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))', + {}, +); + +const useSelectorMock = jest.spyOn(reactRedux, 'useSelector'); +const userSelectorMock = jest.spyOn(reactRedux, 'useSelector'); describe('DatabaseList', () => { useSelectorMock.mockReturnValue({ @@ -115,10 +103,27 @@ describe('DatabaseList', () => { COLUMNAR_EXTENSIONS: ['parquet', 'zip'], ALLOWED_EXTENSIONS: ['parquet', 'zip', 'xls', 'xlsx', 'csv'], }); + userSelectorMock.mockReturnValue({ + createdOn: '2021-04-27T18:12:38.952304', + email: 'admin', + firstName: 'admin', + isActive: true, + lastName: 'admin', + permissions: {}, + roles: { + Admin: [ + ['can_sqllab', 'Superset'], + ['can_write', 'Dashboard'], + ['can_write', 'Chart'], + ], + }, + userId: 1, + username: 'admin', + }); const wrapper = mount( - + , ); @@ -144,7 +149,7 @@ describe('DatabaseList', () => { it('fetches Databases', () => { const callsD = fetchMock.calls(/database\/\?q/); - expect(callsD).toHaveLength(1); + expect(callsD).toHaveLength(2); expect(callsD[0][0]).toMatchInlineSnapshot( `"http://localhost/api/v1/database/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`, ); @@ -208,44 +213,3 @@ describe('DatabaseList', () => { ); }); }); - -describe('RTL', () => { - async function renderAndWait() { - const mounted = act(async () => { - render( - - - , - { useRedux: true }, - mockAppState, - ); - }); - - return mounted; - } - - let isFeatureEnabledMock; - beforeEach(async () => { - isFeatureEnabledMock = jest - .spyOn(featureFlags, 'isFeatureEnabled') - .mockImplementation(() => true); - await renderAndWait(); - }); - - afterEach(() => { - cleanup(); - isFeatureEnabledMock.mockRestore(); - }); - - it('renders an "Import Database" tooltip under import button', async () => { - const importButton = await screen.findByTestId('import-button'); - userEvent.hover(importButton); - - await screen.findByRole('tooltip'); - const importTooltip = screen.getByRole('tooltip', { - name: 'Import databases', - }); - - expect(importTooltip).toBeInTheDocument(); - }); -}); diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx index 10149bc9e8a16..df4ef3cf02a40 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx @@ -17,7 +17,8 @@ * under the License. */ import { SupersetClient, t, styled } from '@superset-ui/core'; -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; +import rison from 'rison'; import { useSelector } from 'react-redux'; import Loading from 'src/components/Loading'; import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; @@ -28,9 +29,9 @@ import SubMenu, { SubMenuProps } from 'src/views/components/SubMenu'; import DeleteModal from 'src/components/DeleteModal'; import { Tooltip } from 'src/components/Tooltip'; import Icons from 'src/components/Icons'; +import { isUserAdmin } from 'src/dashboard/util/findPermission'; import ListView, { FilterOperator, Filters } from 'src/components/ListView'; import { commonMenuData } from 'src/views/CRUD/data/common'; -import ImportModelsModal from 'src/components/ImportModal/index'; import handleResourceExport from 'src/utils/export'; import { ExtentionConfigs } from 'src/views/components/types'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; @@ -39,17 +40,6 @@ import DatabaseModal from './DatabaseModal'; import { DatabaseObject } from './types'; const PAGE_SIZE = 25; -const PASSWORDS_NEEDED_MESSAGE = t( - 'The passwords for the databases below are needed in order to ' + - 'import them. Please note that the "Secure Extra" and "Certificate" ' + - 'sections of the database configuration are not present in export ' + - 'files, and should be added manually after the import if they are needed.', -); -const CONFIRM_OVERWRITE_MESSAGE = t( - 'You are importing one or more databases that already exist. ' + - 'Overwriting might cause you to lose some of your work. Are you ' + - 'sure you want to overwrite?', -); interface DatabaseDeleteObject extends DatabaseObject { chart_count: number; @@ -97,18 +87,22 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { t('database'), addDangerToast, ); + const user = useSelector( + state => state.user, + ); + const [databaseModalOpen, setDatabaseModalOpen] = useState(false); const [databaseCurrentlyDeleting, setDatabaseCurrentlyDeleting] = useState(null); const [currentDatabase, setCurrentDatabase] = useState( null, ); - const [importingDatabase, showImportModal] = useState(false); - const [passwordFields, setPasswordFields] = useState([]); + const [allowUploads, setAllowUploads] = useState(false); + const isAdmin = isUserAdmin(user); + const showUploads = allowUploads || isAdmin; + const [preparingExport, setPreparingExport] = useState(false); - const { roles } = useSelector( - state => state.user, - ); + const { roles } = user; const { CSV_EXTENSIONS, COLUMNAR_EXTENSIONS, @@ -116,20 +110,6 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { ALLOWED_EXTENSIONS, } = useSelector(state => state.common.conf); - const openDatabaseImportModal = () => { - showImportModal(true); - }; - - const closeDatabaseImportModal = () => { - showImportModal(false); - }; - - const handleDatabaseImport = () => { - showImportModal(false); - refreshData(); - addSuccessToast(t('Database imported')); - }; - const openDatabaseDeleteModal = (database: DatabaseObject) => SupersetClient.get({ endpoint: `/api/v1/database/${database.id}/related_objects/`, @@ -191,6 +171,8 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { ALLOWED_EXTENSIONS, ); + const isDisabled = isAdmin && !allowUploads; + const uploadDropdownMenu = [ { label: t('Upload file to database'), @@ -199,24 +181,42 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { label: t('Upload CSV'), name: 'Upload CSV file', url: '/csvtodatabaseview/form', - perm: canUploadCSV, + perm: canUploadCSV && showUploads, + disable: isDisabled, }, { label: t('Upload columnar file'), name: 'Upload columnar file', url: '/columnartodatabaseview/form', - perm: canUploadColumnar, + perm: canUploadColumnar && showUploads, + disable: isDisabled, }, { label: t('Upload Excel file'), name: 'Upload Excel file', url: '/exceltodatabaseview/form', - perm: canUploadExcel, + perm: canUploadExcel && showUploads, + disable: isDisabled, }, ], }, ]; + const hasFileUploadEnabled = () => { + const payload = { + filters: [ + { col: 'allow_file_upload', opr: 'upload_is_enabled', value: true }, + ], + }; + SupersetClient.get({ + endpoint: `/api/v1/database/?q=${rison.encode(payload)}`, + }).then(({ json }: Record) => { + setAllowUploads(json.count >= 1); + }); + }; + + useEffect(() => hasFileUploadEnabled(), [databaseModalOpen]); + const filteredDropDown = uploadDropdownMenu.map(link => { // eslint-disable-next-line no-param-reassign link.childs = link.childs.filter(item => item.perm); @@ -245,22 +245,6 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { }, }, ]; - - if (isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT)) { - menuData.buttons.push({ - name: ( - - - - ), - buttonStyle: 'link', - onClick: openDatabaseImportModal, - }); - } } function handleDatabaseExport(database: DatabaseObject) { @@ -526,19 +510,6 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { pageSize={PAGE_SIZE} /> - {preparingExport && } ); diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/ModalHeader.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/ModalHeader.tsx index 992aa76e36060..7cdcbaba281eb 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/ModalHeader.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/ModalHeader.tsx @@ -19,6 +19,7 @@ import React from 'react'; import { getDatabaseDocumentationLinks } from 'src/views/CRUD/hooks'; +import { UploadFile } from 'antd/lib/upload/interface'; import { EditHeaderTitle, EditHeaderSubtitle, @@ -52,6 +53,7 @@ const documentationLink = (engine: string | undefined) => { } return irregularDocumentationLinks[engine]; }; + const ModalHeader = ({ isLoading, isEditMode, @@ -61,6 +63,7 @@ const ModalHeader = ({ dbName, dbModel, editNewDb, + fileList, }: { isLoading: boolean; isEditMode: boolean; @@ -70,13 +73,19 @@ const ModalHeader = ({ dbName: string; dbModel: DatabaseForm; editNewDb?: boolean; + fileList?: UploadFile[]; + passwordFields?: string[]; + needsOverwriteConfirm?: boolean; }) => { + const fileCheck = fileList && fileList?.length > 0; + const isEditHeader = ( {db?.backend} {dbName} ); + const useSqlAlchemyFormHeader = (

STEP 2 OF 2

@@ -94,6 +103,7 @@ const ModalHeader = ({

); + const hasConnectedDbHeader = ( @@ -115,6 +125,7 @@ const ModalHeader = ({ ); + const hasDbHeader = ( @@ -133,6 +144,7 @@ const ModalHeader = ({ ); + const noDbHeader = (
@@ -142,19 +154,23 @@ const ModalHeader = ({ ); + const importDbHeader = ( + + +

STEP 2 OF 2

+

Enter the required {dbModel.name} credentials

+

{fileCheck ? fileList[0].name : ''}

+
+
+ ); + + if (fileCheck) return importDbHeader; if (isLoading) return <>; - if (isEditMode) { - return isEditHeader; - } - if (useSqlAlchemyForm) { - return useSqlAlchemyFormHeader; - } - if (hasConnectedDb && !editNewDb) { - return hasConnectedDbHeader; - } - if (db || editNewDb) { - return hasDbHeader; - } + if (isEditMode) return isEditHeader; + if (useSqlAlchemyForm) return useSqlAlchemyFormHeader; + if (hasConnectedDb && !editNewDb) return hasConnectedDbHeader; + if (db || editNewDb) return hasDbHeader; + return noDbHeader; }; diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx index 9db2333573dfa..79a11b0b13438 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx @@ -1028,7 +1028,24 @@ describe('DatabaseModal', () => { */ }); }); + + describe('Import database flow', () => { + it('imports a file', () => { + const importDbButton = screen.getByTestId('import-database-btn'); + expect(importDbButton).toBeVisible(); + + const testFile = new File([new ArrayBuffer(1)], 'model_export.zip'); + + userEvent.click(importDbButton); + userEvent.upload(importDbButton, testFile); + + expect(importDbButton.files[0]).toStrictEqual(testFile); + expect(importDbButton.files.item(0)).toStrictEqual(testFile); + expect(importDbButton.files).toHaveLength(1); + }); + }); }); + describe('DatabaseModal w/ Deeplinking Engine', () => { const renderAndWait = async () => { const mounted = act(async () => { diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx index a92d6f6440eda..c4faa8a483ebe 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx @@ -25,18 +25,21 @@ import { import React, { FunctionComponent, useEffect, + useRef, useState, useReducer, Reducer, } from 'react'; +import { UploadChangeParam, UploadFile } from 'antd/lib/upload/interface'; import Tabs from 'src/components/Tabs'; -import { AntdSelect } from 'src/components'; +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 InfoTooltip from 'src/components/InfoTooltip'; import withToasts from 'src/components/MessageToasts/withToasts'; +import ValidatedInput from 'src/components/Form/LabeledErrorBoundInput'; import { testDatabaseConnection, useSingleViewResource, @@ -44,6 +47,7 @@ import { useDatabaseValidation, getDatabaseImages, getConnectionAlert, + useImportResource, } from 'src/views/CRUD/hooks'; import { useCommonConf } from 'src/views/CRUD/data/database/state'; import { @@ -59,11 +63,13 @@ import DatabaseConnectionForm from './DatabaseConnectionForm'; import { antDErrorAlertStyles, antDAlertStyles, + antdWarningAlertStyles, StyledAlertMargin, antDModalNoPaddingStyles, antDModalStyles, antDTabsStyles, buttonLinkStyles, + importDbButtonLinkStyles, alchemyButtonLinkStyles, TabHeader, formHelperStyles, @@ -73,6 +79,8 @@ import { infoTooltip, StyledFooterButton, StyledStickyHeader, + formScrollableStyles, + StyledUploadWrapper, } from './styles'; import ModalHeader, { DOCUMENTATION_LINK } from './ModalHeader'; @@ -89,6 +97,9 @@ const engineSpecificAlertMapping = { }; const errorAlertMapping = { + GENERIC_DB_ENGINE_ERROR: { + message: t('Generic database engine error'), + }, CONNECTION_MISSING_PARAMETERS_ERROR: { message: t('Missing Required Fields'), description: t('Please complete all required fields.'), @@ -128,6 +139,8 @@ const errorAlertMapping = { ), }, }; +const googleSheetConnectionEngine = 'gsheets'; + interface DatabaseModalProps { addDangerToast: (msg: string) => void; addSuccessToast: (msg: string) => void; @@ -368,11 +381,11 @@ function dbReducer( action.payload.configuration_method === CONFIGURATION_METHOD.DYNAMIC_FORM ) { - const engineParamsCatalog = Object.keys( + const engineParamsCatalog = Object.entries( extra_json?.engine_params?.catalog || {}, - ).map(e => ({ - name: e, - value: extra_json?.engine_params?.catalog[e], + ).map(([key, value]) => ({ + name: key, + value, })); return { ...action.payload, @@ -399,10 +412,12 @@ function dbReducer( return { ...action.payload, }; + case ActionType.configMethodChange: return { ...action.payload, }; + case ActionType.reset: default: return null; @@ -415,9 +430,7 @@ const serializeExtra = (extraJson: DatabaseObject['extra_json']) => JSON.stringify({ ...extraJson, metadata_params: JSON.parse((extraJson?.metadata_params as string) || '{}'), - engine_params: JSON.parse( - (extraJson?.engine_params as unknown as string) || '{}', - ), + engine_params: JSON.parse((extraJson?.engine_params as string) || '{}'), schemas_allowed_for_file_upload: ( extraJson?.schemas_allowed_for_file_upload || [] ).filter(schema => schema !== ''), @@ -435,6 +448,19 @@ const DatabaseModal: FunctionComponent = ({ const [db, setDB] = useReducer< Reducer | null, DBReducerActionType> >(dbReducer, null); + // Database fetch logic + const { + state: { loading: dbLoading, resource: dbFetched, error: dbErrors }, + fetchResource, + createResource, + updateResource, + clearError, + } = useSingleViewResource( + 'database', + t('database'), + addDangerToast, + ); + const [tabKey, setTabKey] = useState(DEFAULT_TAB_KEY); const [availableDbs, getAvailableDbs] = useAvailableDatabases(); const [validationErrors, getValidation, setValidationErrors] = @@ -444,6 +470,11 @@ const DatabaseModal: FunctionComponent = ({ const [editNewDb, setEditNewDb] = useState(false); const [isLoading, setLoading] = useState(false); const [testInProgress, setTestInProgress] = useState(false); + const [passwords, setPasswords] = useState>({}); + const [confirmedOverwrite, setConfirmedOverwrite] = useState(false); + const [fileList, setFileList] = useState([]); + const [importingModal, setImportingModal] = useState(false); + const conf = useCommonConf(); const dbImages = getDatabaseImages(); const connectionAlert = getConnectionAlert(); @@ -456,18 +487,6 @@ const DatabaseModal: FunctionComponent = ({ const useSqlAlchemyForm = db?.configuration_method === CONFIGURATION_METHOD.SQLALCHEMY_URI; const useTabLayout = isEditMode || useSqlAlchemyForm; - // Database fetch logic - const { - state: { loading: dbLoading, resource: dbFetched, error: dbErrors }, - fetchResource, - createResource, - updateResource, - clearError, - } = useSingleViewResource( - 'database', - t('database'), - addDangerToast, - ); const isDynamic = (engine: string | undefined) => availableDbs?.databases?.find( (DB: DatabaseObject) => DB.backend === engine || DB.engine === engine, @@ -512,14 +531,43 @@ const DatabaseModal: FunctionComponent = ({ ); }; + const removeFile = (removedFile: UploadFile) => { + setFileList(fileList.filter(file => file.uid !== removedFile.uid)); + return false; + }; + const onClose = () => { setDB({ type: ActionType.reset }); setHasConnectedDb(false); setValidationErrors(null); // reset validation errors on close clearError(); setEditNewDb(false); + setFileList([]); + setImportingModal(false); + setPasswords({}); + setConfirmedOverwrite(false); + if (onDatabaseAdd) onDatabaseAdd(); onHide(); }; + + // Database import logic + const { + state: { + alreadyExists, + passwordsNeeded, + loading: importLoading, + failed: importErrored, + }, + importResource, + } = useImportResource('database', t('database'), msg => { + addDangerToast(msg); + onClose(); + }); + + const onChange = (type: any, payload: any) => { + setDB({ type, payload } as DBReducerActionType); + }; + const onSave = async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { id, ...update } = db || {}; @@ -595,9 +643,7 @@ const DatabaseModal: FunctionComponent = ({ dbToUpdate.configuration_method === CONFIGURATION_METHOD.DYNAMIC_FORM, // onShow toast on SQLA Forms ); if (result) { - if (onDatabaseAdd) { - onDatabaseAdd(); - } + if (onDatabaseAdd) onDatabaseAdd(); if (!editNewDb) { onClose(); addSuccessToast(t('Database settings updated')); @@ -612,9 +658,7 @@ const DatabaseModal: FunctionComponent = ({ ); if (dbId) { setHasConnectedDb(true); - if (onDatabaseAdd) { - onDatabaseAdd(); - } + if (onDatabaseAdd) onDatabaseAdd(); if (useTabLayout) { // tab layout only has one step // so it should close immediately on save @@ -623,14 +667,29 @@ const DatabaseModal: FunctionComponent = ({ } } } + + // Import - doesn't use db state + if (!db) { + setLoading(true); + setImportingModal(true); + + if (!(fileList[0].originFileObj instanceof File)) return; + const dbId = await importResource( + fileList[0].originFileObj, + passwords, + confirmedOverwrite, + ); + + if (dbId) { + onClose(); + addSuccessToast(t('Database connected')); + } + } + setEditNewDb(false); setLoading(false); }; - const onChange = (type: any, payload: any) => { - setDB({ type, payload } as DBReducerActionType); - }; - // Initialize const fetchDB = () => { if (isEditMode && databaseId) { @@ -772,10 +831,20 @@ const DatabaseModal: FunctionComponent = ({ }; const handleBackButtonOnConnect = () => { - if (editNewDb) { - setHasConnectedDb(false); - } + if (editNewDb) setHasConnectedDb(false); + if (importingModal) setImportingModal(false); setDB({ type: ActionType.reset }); + setFileList([]); + }; + + const handleDisableOnImport = () => { + if ( + importLoading || + (alreadyExists.length && !confirmedOverwrite) || + (passwordsNeeded.length && JSON.stringify(passwords) === '{}') + ) + return true; + return false; }; const renderModalFooter = () => { @@ -814,6 +883,26 @@ const DatabaseModal: FunctionComponent = ({ ); } + + // Import doesn't use db state, so footer will not render in the if statement above + if (importingModal) { + return ( + <> + + {t('Back')} + + + {t('Connect')} + + + ); + } + return []; }; @@ -839,6 +928,28 @@ const DatabaseModal: FunctionComponent = ({ ); + + const firstUpdate = useRef(true); // Captures first render + // Only runs when importing files don't need user input + useEffect(() => { + // Will not run on first render + if (firstUpdate.current) { + firstUpdate.current = false; + return; + } + + if ( + !importLoading && + !alreadyExists.length && + !passwordsNeeded.length && + !isLoading && // This prevents a double toast for non-related imports + !importErrored // This prevents a success toast on error + ) { + onClose(); + addSuccessToast(t('Database connected')); + } + }, [alreadyExists, passwordsNeeded, importLoading, importErrored]); + useEffect(() => { if (show) { setTabKey(DEFAULT_TAB_KEY); @@ -873,19 +984,111 @@ const DatabaseModal: FunctionComponent = ({ } }, [availableDbs]); - const tabChange = (key: string) => { - setTabKey(key); + // This forces the modal to scroll until the importing filename is in view + useEffect(() => { + if (importingModal) { + document + .getElementsByClassName('ant-upload-list-item-name')[0] + .scrollIntoView(); + } + }, [importingModal]); + + const onDbImport = async (info: UploadChangeParam) => { + setImportingModal(true); + setFileList([ + { + ...info.file, + status: 'done', + }, + ]); + + if (!(info.file.originFileObj instanceof File)) return; + await importResource( + info.file.originFileObj, + passwords, + confirmedOverwrite, + ); }; + const passwordNeededField = () => { + if (!passwordsNeeded.length) return null; + + return passwordsNeeded.map(database => ( + <> + + antDAlertStyles(theme)} + type="info" + showIcon + message="Database passwords" + description={t( + `The passwords for the databases below are needed in order to import them. Please note that the "Secure Extra" and "Certificate" sections of the database configuration are not present in explore files and should be added manually after the import if they are needed.`, + )} + /> + + ) => + setPasswords({ ...passwords, [database]: event.target.value }) + } + validationMethods={{ onBlur: () => {} }} + errorMessage={validationErrors?.password_needed} + label={t('%s PASSWORD', database.slice(10))} + css={formScrollableStyles} + /> + + )); + }; + + const confirmOverwrite = (event: React.ChangeEvent) => { + const targetValue = (event.currentTarget?.value as string) ?? ''; + setConfirmedOverwrite(targetValue.toUpperCase() === t('OVERWRITE')); + }; + + const confirmOverwriteField = () => { + if (!alreadyExists.length) return null; + + return ( + <> + + antdWarningAlertStyles(theme)} + type="warning" + showIcon + message="" + description={t( + 'You are importing one or more databases that already exist. Overwriting might cause you to lose some of your work. Are you sure you want to overwrite?', + )} + /> + + {} }} + errorMessage={validationErrors?.confirm_overwrite} + label={t(`TYPE "OVERWRITE" TO CONFIRM`)} + onChange={confirmOverwrite} + css={formScrollableStyles} + /> + + ); + }; + + const tabChange = (key: string) => setTabKey(key); + const renderStepTwoAlert = () => { const { hostname } = window.location; let ipAlert = connectionAlert?.REGIONAL_IPS?.default || ''; const regionalIPs = connectionAlert?.REGIONAL_IPS || {}; Object.entries(regionalIPs).forEach(([ipRegion, ipRange]) => { const regex = new RegExp(ipRegion); - if (hostname.match(regex)) { - ipAlert = ipRange; - } + if (hostname.match(regex)) ipAlert = ipRange; }); return ( db?.engine && ( @@ -929,6 +1132,7 @@ const DatabaseModal: FunctionComponent = ({ } description={ errorAlertMapping[validationErrors?.error_type]?.description || + validationErrors?.description || JSON.stringify(validationErrors) } showIcon @@ -1025,6 +1229,41 @@ const DatabaseModal: FunctionComponent = ({ ); }; + if (fileList.length > 0 && (alreadyExists.length || passwordsNeeded.length)) { + return ( + [ + antDModalNoPaddingStyles, + antDModalStyles(theme), + formHelperStyles(theme), + formStyles(theme), + ]} + name="database" + onHandledPrimaryAction={onSave} + onHide={onClose} + primaryButtonName={t('Connect')} + width="500px" + centered + show={show} + title={

{t('Connect a database')}

} + footer={renderModalFooter()} + > + + {passwordNeededField()} + {confirmOverwriteField()} +
+ ); + } + return useTabLayout ? ( [ @@ -1264,6 +1503,26 @@ const DatabaseModal: FunctionComponent = ({ /> {renderPreferredSelector()} {renderAvailableSelector()} + + {}} + onChange={onDbImport} + onRemove={removeFile} + > + + + ) : ( <> @@ -1318,32 +1577,36 @@ const DatabaseModal: FunctionComponent = ({ validationErrors={validationErrors} />
infoTooltip(theme)}> - - + {dbModel.engine !== googleSheetConnectionEngine && ( + <> + + + + )}
{/* Step 2 */} {showDBError && errorAlert()} diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts index c0e65b97774cc..39302168b2f07 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts @@ -218,6 +218,29 @@ export const antDErrorAlertStyles = (theme: SupersetTheme) => css` } `; +export const antdWarningAlertStyles = (theme: SupersetTheme) => css` + border: 1px solid ${theme.colors.warning.light1}; + padding: ${theme.gridUnit * 4}px; + margin: ${theme.gridUnit * 4}px 0; + color: ${theme.colors.warning.dark2}; + + .ant-alert-message { + margin: 0; + } + + .ant-alert-description { + font-size: ${theme.typography.sizes.s + 1}px; + line-height: ${theme.gridUnit * 4}px; + + .ant-alert-icon { + margin-right: ${theme.gridUnit * 2.5}px; + font-size: ${theme.typography.sizes.l + 1}px; + position: relative; + top: ${theme.gridUnit / 4}px; + } + } +`; + export const formHelperStyles = (theme: SupersetTheme) => css` .required { margin-left: ${theme.gridUnit / 2}px; @@ -399,6 +422,13 @@ export const buttonLinkStyles = (theme: SupersetTheme) => css` padding-right: ${theme.gridUnit * 2}px; `; +export const importDbButtonLinkStyles = (theme: SupersetTheme) => css` + font-size: ${theme.gridUnit * 3.5}px; + font-weight: ${theme.typography.weights.normal}; + text-transform: initial; + padding-right: ${theme.gridUnit * 2}px; +`; + export const alchemyButtonLinkStyles = (theme: SupersetTheme) => css` font-weight: ${theme.typography.weights.normal}; text-transform: initial; @@ -583,3 +613,13 @@ export const StyledCatalogTable = styled.div` width: 95%; } `; + +export const StyledUploadWrapper = styled.div` + .ant-progress-inner { + display: none; + } + + .ant-upload-list-item-card-actions { + display: none; + } +`; diff --git a/superset-frontend/src/views/CRUD/data/database/types.ts b/superset-frontend/src/views/CRUD/data/database/types.ts index f8e1a7806e316..d48fa956e28b2 100644 --- a/superset-frontend/src/views/CRUD/data/database/types.ts +++ b/superset-frontend/src/views/CRUD/data/database/types.ts @@ -79,7 +79,7 @@ export type DatabaseObject = { // Extra extra_json?: { engine_params?: { - catalog: Record | string; + catalog?: Record | string; }; metadata_params?: {} | string; metadata_cache_timeout?: { diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx b/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx index f3ad4e488c2c4..7e7e7429bddd3 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx +++ b/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx @@ -126,10 +126,10 @@ const DatasetModal: FunctionComponent = ({ formMode database={currentDatabase} schema={currentSchema} - tableName={currentTableName} + tableValue={currentTableName} onDbChange={onDbChange} onSchemaChange={onSchemaChange} - onTableChange={onTableChange} + onTableSelectChange={onTableChange} handleError={addDangerToast} /> diff --git a/superset-frontend/src/views/CRUD/data/query/QueryPreviewModal.tsx b/superset-frontend/src/views/CRUD/data/query/QueryPreviewModal.tsx index 397970e1b2941..694b490557001 100644 --- a/superset-frontend/src/views/CRUD/data/query/QueryPreviewModal.tsx +++ b/superset-frontend/src/views/CRUD/data/query/QueryPreviewModal.tsx @@ -30,14 +30,14 @@ import { QueryObject } from 'src/views/CRUD/types'; const QueryTitle = styled.div` color: ${({ theme }) => theme.colors.secondary.light2}; - font-size: ${({ theme }) => theme.typography.sizes.s - 1}px; + font-size: ${({ theme }) => theme.typography.sizes.s}px; margin-bottom: 0; text-transform: uppercase; `; const QueryLabel = styled.div` color: ${({ theme }) => theme.colors.grayscale.dark2}; - font-size: ${({ theme }) => theme.typography.sizes.m - 1}px; + font-size: ${({ theme }) => theme.typography.sizes.m}px; padding: 4px 0 24px 0; `; diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx index 883ce3b695005..e8250d0fb7f80 100644 --- a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx +++ b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx @@ -28,14 +28,14 @@ import { useQueryPreviewState } from 'src/views/CRUD/data/hooks'; const QueryTitle = styled.div` color: ${({ theme }) => theme.colors.secondary.light2}; - font-size: ${({ theme }) => theme.typography.sizes.s - 1}px; + font-size: ${({ theme }) => theme.typography.sizes.s}px; margin-bottom: 0; text-transform: uppercase; `; const QueryLabel = styled.div` color: ${({ theme }) => theme.colors.grayscale.dark2}; - font-size: ${({ theme }) => theme.typography.sizes.m - 1}px; + font-size: ${({ theme }) => theme.typography.sizes.m}px; padding: 4px 0 16px 0; `; diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index b0ca13d96a2d6..5a0e26131efc0 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -381,6 +381,7 @@ interface ImportResourceState { loading: boolean; passwordsNeeded: string[]; alreadyExists: string[]; + failed: boolean; } export function useImportResource( @@ -392,6 +393,7 @@ export function useImportResource( loading: false, passwordsNeeded: [], alreadyExists: [], + failed: false, }); function updateState(update: Partial) { @@ -407,6 +409,7 @@ export function useImportResource( // Set loading state updateState({ loading: true, + failed: false, }); const formData = new FormData(); @@ -430,9 +433,19 @@ export function useImportResource( body: formData, headers: { Accept: 'application/json' }, }) - .then(() => true) + .then(() => { + updateState({ + passwordsNeeded: [], + alreadyExists: [], + failed: false, + }); + return true; + }) .catch(response => getClientErrorObject(response).then(error => { + updateState({ + failed: true, + }); if (!error.errors) { handleErrorMsg( t( @@ -448,7 +461,10 @@ export function useImportResource( t( 'An error occurred while importing %s: %s', resourceLabel, - error.errors.map(payload => payload.message).join('\n'), + [ + ...error.errors.map(payload => payload.message), + t('Please re-export your file and try importing again'), + ].join('\n'), ), ); } else { @@ -689,6 +705,10 @@ export function useDatabaseValidation() { url: string; idx: number; }; + issue_codes?: { + code?: number; + message?: string; + }[]; }; message: string; }, @@ -744,6 +764,14 @@ export function useDatabaseValidation() { ), }; } + if (extra.issue_codes?.length) { + return { + ...obj, + error_type, + description: message || extra.issue_codes[0]?.message, + }; + } + return obj; }, {}, diff --git a/superset-frontend/src/views/CRUD/utils.test.tsx b/superset-frontend/src/views/CRUD/utils.test.tsx index 68d377e3102cf..dcce0b83697ed 100644 --- a/superset-frontend/src/views/CRUD/utils.test.tsx +++ b/superset-frontend/src/views/CRUD/utils.test.tsx @@ -46,6 +46,25 @@ const terminalErrors = { ], }; +const terminalErrorsWithOnlyIssuesCode = { + errors: [ + { + message: 'Error importing database', + error_type: 'GENERIC_COMMAND_ERROR', + level: 'warning', + extra: { + issue_codes: [ + { + code: 1010, + message: + 'Issue 1010 - Superset encountered an error while running a command.', + }, + ], + }, + }, + ], +}; + const overwriteNeededErrors = { errors: [ { @@ -146,6 +165,12 @@ test('detects if the error message is terminal or if it requires uses interventi expect(isTerminal).toBe(false); }); +test('error message is terminal when the "extra" field contains only the "issue_codes" key', () => { + expect(hasTerminalValidation(terminalErrorsWithOnlyIssuesCode.errors)).toBe( + true, + ); +}); + test('does not ask for password when the import type is wrong', () => { const error = { errors: [ diff --git a/superset-frontend/src/views/CRUD/utils.tsx b/superset-frontend/src/views/CRUD/utils.tsx index 3449d764abfa2..31f3d4c9edeb1 100644 --- a/superset-frontend/src/views/CRUD/utils.tsx +++ b/superset-frontend/src/views/CRUD/utils.tsx @@ -285,7 +285,7 @@ export function handleDashboardDelete( addSuccessToast: (arg0: string) => void, addDangerToast: (arg0: string) => void, dashboardFilter?: string, - userId?: number, + userId?: string | number, ) { return SupersetClient.delete({ endpoint: `/api/v1/dashboard/${id}`, @@ -399,15 +399,17 @@ export const getAlreadyExists = (errors: Record[]) => .flat(); export const hasTerminalValidation = (errors: Record[]) => - errors.some( - error => - !Object.entries(error.extra) - .filter(([key, _]) => key !== 'issue_codes') - .every( - ([_, payload]) => - isNeedsPassword(payload) || isAlreadyExists(payload), - ), - ); + errors.some(error => { + const noIssuesCodes = Object.entries(error.extra).filter( + ([key]) => key !== 'issue_codes', + ); + + if (noIssuesCodes.length === 0) return true; + + return !noIssuesCodes.every( + ([, payload]) => isNeedsPassword(payload) || isAlreadyExists(payload), + ); + }); export const checkUploadExtensions = ( perm: Array, @@ -425,14 +427,20 @@ export const uploadUserPerms = ( colExt: Array, excelExt: Array, allowedExt: Array, -) => ({ - canUploadCSV: +) => { + const canUploadCSV = findPermission('can_this_form_get', 'CsvToDatabaseView', roles) && - checkUploadExtensions(csvExt, allowedExt), - canUploadColumnar: + checkUploadExtensions(csvExt, allowedExt); + const canUploadColumnar = checkUploadExtensions(colExt, allowedExt) && - findPermission('can_this_form_get', 'ColumnarToDatabaseView', roles), - canUploadExcel: + findPermission('can_this_form_get', 'ColumnarToDatabaseView', roles); + const canUploadExcel = checkUploadExtensions(excelExt, allowedExt) && - findPermission('can_this_form_get', 'ExcelToDatabaseView', roles), -}); + findPermission('can_this_form_get', 'ExcelToDatabaseView', roles); + return { + canUploadCSV, + canUploadColumnar, + canUploadExcel, + canUploadData: canUploadCSV || canUploadColumnar || canUploadExcel, + }; +}; diff --git a/superset-frontend/src/views/CRUD/welcome/ActivityTable.test.tsx b/superset-frontend/src/views/CRUD/welcome/ActivityTable.test.tsx index 2fd7feb977ed5..71067b817a3e2 100644 --- a/superset-frontend/src/views/CRUD/welcome/ActivityTable.test.tsx +++ b/superset-frontend/src/views/CRUD/welcome/ActivityTable.test.tsx @@ -100,14 +100,14 @@ describe('ActivityTable', () => { expect(wrapper.find(ActivityTable)).toExist(); }); it('renders tabs with three buttons', () => { - expect(wrapper.find('li.no-router')).toHaveLength(3); + expect(wrapper.find('[role="tab"]')).toHaveLength(3); }); it('renders ActivityCards', async () => { expect(wrapper.find('ListViewCard')).toExist(); }); it('calls the getEdited batch call when edited tab is clicked', async () => { act(() => { - const handler = wrapper.find('li.no-router a').at(1).prop('onClick'); + const handler = wrapper.find('[role="tab"] a').at(1).prop('onClick'); if (handler) { handler({} as any); } diff --git a/superset-frontend/src/views/CRUD/welcome/ChartTable.test.tsx b/superset-frontend/src/views/CRUD/welcome/ChartTable.test.tsx index c61cb1b33e1b3..cfa9230328c08 100644 --- a/superset-frontend/src/views/CRUD/welcome/ChartTable.test.tsx +++ b/superset-frontend/src/views/CRUD/welcome/ChartTable.test.tsx @@ -79,7 +79,7 @@ describe('ChartTable', () => { it('fetches chart favorites and renders chart cards', async () => { act(() => { - const handler = wrapper.find('li.no-router a').at(0).prop('onClick'); + const handler = wrapper.find('[role="tab"] a').at(0).prop('onClick'); if (handler) { handler({} as any); } diff --git a/superset-frontend/src/views/CRUD/welcome/DashboardTable.test.tsx b/superset-frontend/src/views/CRUD/welcome/DashboardTable.test.tsx index 26fd3a13d32d7..79f88e3128834 100644 --- a/superset-frontend/src/views/CRUD/welcome/DashboardTable.test.tsx +++ b/superset-frontend/src/views/CRUD/welcome/DashboardTable.test.tsx @@ -71,10 +71,10 @@ describe('DashboardTable', () => { it('render a submenu with clickable tabs and buttons', async () => { expect(wrapper.find('SubMenu')).toExist(); - expect(wrapper.find('li.no-router')).toHaveLength(2); + expect(wrapper.find('[role="tab"]')).toHaveLength(2); expect(wrapper.find('Button')).toHaveLength(6); act(() => { - const handler = wrapper.find('li.no-router a').at(0).prop('onClick'); + const handler = wrapper.find('[role="tab"] a').at(0).prop('onClick'); if (handler) { handler({} as any); } diff --git a/superset-frontend/src/views/CRUD/welcome/EmptyState.tsx b/superset-frontend/src/views/CRUD/welcome/EmptyState.tsx index 379fbe3995a1d..525c9ef62e803 100644 --- a/superset-frontend/src/views/CRUD/welcome/EmptyState.tsx +++ b/superset-frontend/src/views/CRUD/welcome/EmptyState.tsx @@ -122,11 +122,10 @@ export default function EmptyState({ tableName, tab }: EmptyStateProps) { {tableName === 'SAVED_QUERIES' ? t('SQL query') - : t(`${tableName + : tableName .split('') .slice(0, tableName.length - 1) .join('')} - `)} )} diff --git a/superset-frontend/src/views/CRUD/welcome/SavedQueries.test.tsx b/superset-frontend/src/views/CRUD/welcome/SavedQueries.test.tsx index dd883a1aa1ab4..f656a9e8e175f 100644 --- a/superset-frontend/src/views/CRUD/welcome/SavedQueries.test.tsx +++ b/superset-frontend/src/views/CRUD/welcome/SavedQueries.test.tsx @@ -81,7 +81,7 @@ describe('SavedQueries', () => { const clickTab = (idx: number) => { act(() => { - const handler = wrapper.find('li.no-router a').at(idx).prop('onClick'); + const handler = wrapper.find('[role="tab"] a').at(idx).prop('onClick'); if (handler) { handler({} as any); } @@ -105,7 +105,7 @@ describe('SavedQueries', () => { it('renders a submenu with clickable tables and buttons', async () => { expect(wrapper.find(SubMenu)).toExist(); - expect(wrapper.find('li.no-router')).toHaveLength(1); + expect(wrapper.find('[role="tab"]')).toHaveLength(1); expect(wrapper.find('button')).toHaveLength(2); clickTab(0); await waitForComponentToPaint(wrapper); diff --git a/superset-frontend/src/views/CRUD/welcome/Welcome.tsx b/superset-frontend/src/views/CRUD/welcome/Welcome.tsx index 056e02e8f6226..2d564bc66fe9f 100644 --- a/superset-frontend/src/views/CRUD/welcome/Welcome.tsx +++ b/superset-frontend/src/views/CRUD/welcome/Welcome.tsx @@ -113,23 +113,27 @@ const WelcomeContainer = styled.div` `; const WelcomeNav = styled.div` - height: 50px; - background-color: white; - .navbar-brand { - margin-left: ${({ theme }) => theme.gridUnit * 2}px; - font-weight: ${({ theme }) => theme.typography.weights.bold}; - } - .switch { - float: right; - margin: ${({ theme }) => theme.gridUnit * 5}px; + ${({ theme }) => ` display: flex; - flex-direction: row; - span { - display: block; - margin: ${({ theme }) => theme.gridUnit * 1}px; - line-height: 1; + justify-content: space-between; + height: 50px; + background-color: ${theme.colors.grayscale.light5}; + .welcome-header { + font-size: ${theme.typography.sizes.l}px; + padding: ${theme.gridUnit * 4}px ${theme.gridUnit * 2 + 2}px; + margin: 0 ${theme.gridUnit * 2}px; } - } + .switch { + display: flex; + flex-direction: row; + margin: ${theme.gridUnit * 4}px; + span { + display: block; + margin: ${theme.gridUnit * 1}px; + line-height: 1; + } + } + `} `; export const LoadingCards = ({ cover }: LoadingProps) => ( @@ -275,7 +279,7 @@ function Welcome({ user, addDangerToast }: WelcomeProps) { return ( - Home +

Home

{isFeatureEnabled(FeatureFlag.THUMBNAILS) ? (
diff --git a/superset-frontend/src/views/components/Menu.test.tsx b/superset-frontend/src/views/components/Menu.test.tsx index d13275fbc0b57..a80a43a22f02c 100644 --- a/superset-frontend/src/views/components/Menu.test.tsx +++ b/superset-frontend/src/views/components/Menu.test.tsx @@ -18,6 +18,7 @@ */ import React from 'react'; import * as reactRedux from 'react-redux'; +import fetchMock from 'fetch-mock'; import { render, screen } from 'spec/helpers/testing-library'; import userEvent from '@testing-library/user-event'; import { Menu } from './Menu'; @@ -235,6 +236,11 @@ const notanonProps = { const useSelectorMock = jest.spyOn(reactRedux, 'useSelector'); +fetchMock.get( + 'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))', + {}, +); + beforeEach(() => { // setup a DOM element as a render target useSelectorMock.mockClear(); diff --git a/superset-frontend/src/views/components/Menu.tsx b/superset-frontend/src/views/components/Menu.tsx index 55d929e7b7bc4..3cc61f5243057 100644 --- a/superset-frontend/src/views/components/Menu.tsx +++ b/superset-frontend/src/views/components/Menu.tsx @@ -75,6 +75,7 @@ export interface MenuObjectChildProps { isFrontendRoute?: boolean; perm?: string | boolean; view?: string; + disable?: boolean; } export interface MenuObjectProps extends MenuObjectChildProps { @@ -83,101 +84,100 @@ export interface MenuObjectProps extends MenuObjectChildProps { } const StyledHeader = styled.header` - background-color: white; - margin-bottom: 2px; - &:nth-last-of-type(2) nav { - margin-bottom: 2px; - } - - .caret { - display: none; - } - .navbar-brand { - display: flex; - flex-direction: column; - justify-content: center; - /* must be exactly the height of the Antd navbar */ - min-height: 50px; - padding: ${({ theme }) => - `${theme.gridUnit}px ${theme.gridUnit * 2}px ${theme.gridUnit}px ${ - theme.gridUnit * 4 - }px`}; - max-width: ${({ theme }) => `${theme.gridUnit * 37}px`}; - img { - height: 100%; - object-fit: contain; - } - } - .navbar-brand-text { - border-left: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; - border-right: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; - height: 100%; - color: ${({ theme }) => theme.colors.grayscale.dark1}; - padding-left: ${({ theme }) => theme.gridUnit * 4}px; - padding-right: ${({ theme }) => theme.gridUnit * 4}px; - margin-right: ${({ theme }) => theme.gridUnit * 6}px; - font-size: ${({ theme }) => theme.gridUnit * 4}px; - float: left; - display: flex; - flex-direction: column; - justify-content: center; - - span { - max-width: ${({ theme }) => theme.gridUnit * 58}px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - @media (max-width: 1127px) { - display: none; - } - } - .main-nav .ant-menu-submenu-title > svg { - top: ${({ theme }) => theme.gridUnit * 5.25}px; - } - @media (max-width: 767px) { - .navbar-brand { - float: none; - } - } - .ant-menu-horizontal .ant-menu-item { - height: 100%; - line-height: inherit; - } - .ant-menu > .ant-menu-item > a { - padding: ${({ theme }) => theme.gridUnit * 4}px; - } - @media (max-width: 767px) { - .ant-menu-item { - padding: 0 ${({ theme }) => theme.gridUnit * 6}px 0 - ${({ theme }) => theme.gridUnit * 3}px !important; - } - .ant-menu > .ant-menu-item > a { - padding: 0px; - } - .main-nav .ant-menu-submenu-title > svg:nth-child(1) { - display: none; - } - .ant-menu-item-active > a { - &:hover { - color: ${({ theme }) => theme.colors.primary.base} !important; - background-color: transparent !important; + ${({ theme }) => ` + background-color: ${theme.colors.grayscale.light5}; + margin-bottom: 2px; + &:nth-last-of-type(2) nav { + margin-bottom: 2px; } - } - } + .caret { + display: none; + } + .navbar-brand { + display: flex; + flex-direction: column; + justify-content: center; + /* must be exactly the height of the Antd navbar */ + min-height: 50px; + padding: ${theme.gridUnit}px ${theme.gridUnit * 2}px ${ + theme.gridUnit + }px ${theme.gridUnit * 4}px; + max-width: ${theme.gridUnit * 37}px; + img { + height: 100%; + object-fit: contain; + } + } + .navbar-brand-text { + border-left: 1px solid ${theme.colors.grayscale.light2}; + border-right: 1px solid ${theme.colors.grayscale.light2}; + height: 100%; + color: ${theme.colors.grayscale.dark1}; + padding-left: ${theme.gridUnit * 4}px; + padding-right: ${theme.gridUnit * 4}px; + margin-right: ${theme.gridUnit * 6}px; + font-size: ${theme.gridUnit * 4}px; + float: left; + display: flex; + flex-direction: column; + justify-content: center; - .ant-menu-item a { - &:hover { - color: ${({ theme }) => theme.colors.grayscale.dark1}; - background-color: ${({ theme }) => theme.colors.primary.light5}; - border-bottom: none; - margin: 0; - &:after { - opacity: 1; - width: 100%; + span { + max-width: ${theme.gridUnit * 58}px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + @media (max-width: 1127px) { + display: none; + } } - } - } + .main-nav .ant-menu-submenu-title > svg { + top: ${theme.gridUnit * 5.25}px; + } + @media (max-width: 767px) { + .navbar-brand { + float: none; + } + } + .ant-menu-horizontal .ant-menu-item { + height: 100%; + line-height: inherit; + } + .ant-menu > .ant-menu-item > a { + padding: ${theme.gridUnit * 4}px; + } + @media (max-width: 767px) { + .ant-menu-item { + padding: 0 ${theme.gridUnit * 6}px 0 + ${theme.gridUnit * 3}px !important; + } + .ant-menu > .ant-menu-item > a { + padding: 0px; + } + .main-nav .ant-menu-submenu-title > svg:nth-child(1) { + display: none; + } + .ant-menu-item-active > a { + &:hover { + color: ${theme.colors.primary.base} !important; + background-color: transparent !important; + } + } + } + .ant-menu-item a { + &:hover { + color: ${theme.colors.grayscale.dark1}; + background-color: ${theme.colors.primary.light5}; + border-bottom: none; + margin: 0; + &:after { + opacity: 1; + width: 100%; + } + } + } + `} `; const globalStyles = (theme: SupersetTheme) => css` .ant-menu-submenu.ant-menu-submenu-popup.ant-menu.ant-menu-light.ant-menu-submenu-placement-bottomLeft { diff --git a/superset-frontend/src/views/components/MenuRight.tsx b/superset-frontend/src/views/components/MenuRight.tsx index 6495b62912796..1c46f6bcf079d 100644 --- a/superset-frontend/src/views/components/MenuRight.tsx +++ b/superset-frontend/src/views/components/MenuRight.tsx @@ -16,12 +16,20 @@ * specific language governing permissions and limitations * under the License. */ -import React, { Fragment, useState } from 'react'; +import React, { Fragment, useState, useEffect } from 'react'; +import rison from 'rison'; import { MainNav as Menu } from 'src/components/Menu'; -import { t, styled, css, SupersetTheme } from '@superset-ui/core'; +import { + t, + styled, + css, + SupersetTheme, + SupersetClient, +} from '@superset-ui/core'; +import { Tooltip } from 'src/components/Tooltip'; import { Link } from 'react-router-dom'; import Icons from 'src/components/Icons'; -import findPermission from 'src/dashboard/util/findPermission'; +import findPermission, { isUserAdmin } from 'src/dashboard/util/findPermission'; import { useSelector } from 'react-redux'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import LanguagePicker from './LanguagePicker'; @@ -45,6 +53,15 @@ const StyledI = styled.div` color: ${({ theme }) => theme.colors.primary.dark1}; `; +const styledDisabled = (theme: SupersetTheme) => css` + color: ${theme.colors.grayscale.base}; + backgroundColor: ${theme.colors.grayscale.light2}}; + .ant-menu-item:hover { + color: ${theme.colors.grayscale.base}; + cursor: default; + } +`; + const StyledDiv = styled.div<{ align: string }>` display: flex; flex-direction: row; @@ -69,9 +86,11 @@ const RightMenu = ({ navbarRight, isFrontendRoute, }: RightMenuProps) => { - const { roles } = useSelector( + const user = useSelector( state => state.user, ); + + const { roles } = user; const { CSV_EXTENSIONS, COLUMNAR_EXTENSIONS, @@ -86,16 +105,19 @@ const RightMenu = ({ const canChart = findPermission('can_write', 'Chart', roles); const canDatabase = findPermission('can_write', 'Database', roles); - const { canUploadCSV, canUploadColumnar, canUploadExcel } = uploadUserPerms( - roles, - CSV_EXTENSIONS, - COLUMNAR_EXTENSIONS, - EXCEL_EXTENSIONS, - ALLOWED_EXTENSIONS, - ); + const { canUploadData, canUploadCSV, canUploadColumnar, canUploadExcel } = + uploadUserPerms( + roles, + CSV_EXTENSIONS, + COLUMNAR_EXTENSIONS, + EXCEL_EXTENSIONS, + ALLOWED_EXTENSIONS, + ); - const canUpload = canUploadCSV || canUploadColumnar || canUploadExcel; const showActionDropdown = canSql || canChart || canDashboard; + const [allowUploads, setAllowUploads] = useState(false); + const isAdmin = isUserAdmin(user); + const showUploads = allowUploads || isAdmin; const dropdownItems: MenuObjectProps[] = [ { label: t('Data'), @@ -115,19 +137,19 @@ const RightMenu = ({ label: t('Upload CSV to database'), name: 'Upload a CSV', url: '/csvtodatabaseview/form', - perm: canUploadCSV, + perm: canUploadCSV && showUploads, }, { label: t('Upload columnar file to database'), name: 'Upload a Columnar file', url: '/columnartodatabaseview/form', - perm: canUploadColumnar, + perm: canUploadColumnar && showUploads, }, { label: t('Upload Excel file to database'), name: 'Upload Excel', url: '/exceltodatabaseview/form', - perm: canUploadExcel, + perm: canUploadExcel && showUploads, }, ], }, @@ -154,6 +176,25 @@ const RightMenu = ({ }, ]; + const checkAllowUploads = () => { + const payload = { + filters: [ + { col: 'allow_file_upload', opr: 'upload_is_enabled', value: true }, + ], + }; + SupersetClient.get({ + endpoint: `/api/v1/database/?q=${rison.encode(payload)}`, + }).then(({ json }: Record) => { + setAllowUploads(json.count >= 1); + }); + }; + + useEffect(() => { + if (canUploadData) { + checkAllowUploads(); + } + }, [canUploadData]); + const menuIconAndLabel = (menu: MenuObjectProps) => ( <> @@ -175,14 +216,49 @@ const RightMenu = ({ setShowModal(false); }; + const isDisabled = isAdmin && !allowUploads; + + const tooltipText = t( + "Enable 'Allow data upload' in any database's settings", + ); + + const buildMenuItem = (item: Record) => { + const disabledText = isDisabled && item.url; + return disabledText ? ( + + + {item.label} + + + ) : ( + + {item.url ? {item.label} : item.label} + + ); + }; + + const onMenuOpen = (openKeys: string[]) => { + if (openKeys.length && canUploadData) { + return checkAllowUploads(); + } + return null; + }; + return ( - - + {canDatabase && ( + + )} + {!navbarRight.user_is_anonymous && showActionDropdown && ( } > {dropdownItems.map(menu => { + const canShowChild = menu.childs?.some( + item => typeof item === 'object' && !!item.perm, + ); if (menu.childs) { - return canDatabase || canUpload ? ( - - {menu.childs.map((item, idx) => - typeof item !== 'string' && item.name && item.perm ? ( - - {idx === 2 && } - - {item.url ? ( - {item.label} - ) : ( - item.label - )} - - - ) : null, - )} - - ) : null; + if (canShowChild) { + return ( + + {menu.childs.map((item, idx) => + typeof item !== 'string' && item.name && item.perm ? ( + + {idx === 2 && } + {buildMenuItem(item)} + + ) : null, + )} + + ); + } + if (!menu.url) { + return null; + } } return ( findPermission( diff --git a/superset-frontend/src/views/components/SubMenu.tsx b/superset-frontend/src/views/components/SubMenu.tsx index 4ad3cfe42ede5..2ae897196b78b 100644 --- a/superset-frontend/src/views/components/SubMenu.tsx +++ b/superset-frontend/src/views/components/SubMenu.tsx @@ -18,8 +18,9 @@ */ import React, { ReactNode, useState, useEffect } from 'react'; import { Link, useHistory } from 'react-router-dom'; -import { styled } from '@superset-ui/core'; +import { styled, SupersetTheme, css, t } from '@superset-ui/core'; import cx from 'classnames'; +import { Tooltip } from 'src/components/Tooltip'; import { debounce } from 'lodash'; import { Row } from 'src/components'; import { Menu, MenuMode, MainNav as DropdownMenu } from 'src/components/Menu'; @@ -144,6 +145,15 @@ const StyledHeader = styled.div` } `; +const styledDisabled = (theme: SupersetTheme) => css` + color: ${theme.colors.grayscale.base}; + backgroundColor: ${theme.colors.grayscale.light2}}; + .ant-menu-item:hover { + color: ${theme.colors.grayscale.base}; + cursor: default; + } +`; + type MenuChild = { label: string; name: string; @@ -230,7 +240,7 @@ const SubMenuComponent: React.FunctionComponent = props => { if ((props.usesRouter || hasHistory) && !!tab.usesRouter) { return ( -
  • = props => {
    {tab.label}
    -
  • +
    ); } return ( -
  • = props => { {tab.label} -
  • +
    ); })} @@ -271,7 +281,18 @@ const SubMenuComponent: React.FunctionComponent = props => { > {link.childs?.map(item => { if (typeof item === 'object') { - return ( + return item.disable ? ( + + + {item.label} + + + ) : ( {item.label} diff --git a/superset-frontend/src/visualizations/FilterBox/FilterBox.jsx b/superset-frontend/src/visualizations/FilterBox/FilterBox.jsx index 8f5796644f1af..d734cf943dc67 100644 --- a/superset-frontend/src/visualizations/FilterBox/FilterBox.jsx +++ b/superset-frontend/src/visualizations/FilterBox/FilterBox.jsx @@ -22,7 +22,15 @@ import { debounce } from 'lodash'; import { max as d3Max } from 'd3-array'; import { AsyncCreatableSelect, CreatableSelect } from 'src/components/Select'; import Button from 'src/components/Button'; -import { t, SupersetClient, ensureIsArray } from '@superset-ui/core'; +import { + css, + styled, + t, + SupersetClient, + ensureIsArray, + withTheme, +} from '@superset-ui/core'; +import { Global } from '@emotion/react'; import { BOOL_FALSE_DISPLAY, @@ -43,8 +51,6 @@ import { TIME_FILTER_MAP, } from 'src/explore/constants'; -import './FilterBox.less'; - // a shortcut to a map key, used by many components export const TIME_RANGE = TIME_FILTER_MAP.time_range; @@ -91,6 +97,32 @@ const defaultProps = { instantFiltering: false, }; +const StyledFilterContainer = styled.div` + ${({ theme }) => ` + display: flex; + flex-direction: column; + margin-bottom: ${theme.gridUnit * 2 + 2}px; + + &:last-child { + margin-bottom: 0; + } + + label { + display: flex; + font-weight: ${theme.typography.weights.bold}; + } + + .filter-badge-container { + width: 30px; + padding-right: ${theme.gridUnit * 2 + 2}px; + } + + .filter-badge-container + div { + width: 100%; + } + `} +`; + class FilterBox extends React.PureComponent { constructor(props) { super(props); @@ -409,32 +441,51 @@ class FilterBox extends React.PureComponent { return filtersFields.map(filterConfig => { const { label, key } = filterConfig; return ( -
    + {label} {this.renderSelect(filterConfig)} -
    + ); }); } render() { const { instantFiltering, width, height } = this.props; + const { zIndex, gridUnit } = this.props.theme; return ( -
    - {this.renderDateFilter()} - {this.renderDatasourceFilters()} - {this.renderFilters()} - {!instantFiltering && ( - - )} -
    + <> + div:not(.alert) { + padding-top: 0; + } + + .filter_box { + padding: ${gridUnit * 2 + 2}px 0; + overflow: visible !important; + + &:hover { + z-index: ${zIndex.max}; + } + } + `} + /> +
    + {this.renderDateFilter()} + {this.renderDatasourceFilters()} + {this.renderFilters()} + {!instantFiltering && ( + + )} +
    + ); } } @@ -442,4 +493,4 @@ class FilterBox extends React.PureComponent { FilterBox.propTypes = propTypes; FilterBox.defaultProps = defaultProps; -export default FilterBox; +export default withTheme(FilterBox); diff --git a/superset-frontend/src/visualizations/FilterBox/FilterBox.less b/superset-frontend/src/visualizations/FilterBox/FilterBox.less deleted file mode 100644 index 5a8f0953694f8..0000000000000 --- a/superset-frontend/src/visualizations/FilterBox/FilterBox.less +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -@import '../../assets/stylesheets/less/variables.less'; - -.dashboard .filter_box .slice_container > div:not(.alert) { - padding-top: 0; -} - -.filter_box { - padding: 10px 0; - overflow: visible !important; - - &:hover { - z-index: @z-index-max; - } -} - -.m-b-5 { - margin-bottom: 5px; -} - -.input-inline { - float: left; - padding-right: 3px; - - .dropdown { - display: flex; - button { - padding-right: 20px; - .caret { - width: auto; - position: absolute; - right: 5px; - top: 6px; - } - } - } -} - -.filter-container { - display: flex; - flex-direction: column; - margin-bottom: 10px; - - &:last-child { - margin-bottom: 0; - } - - label { - display: flex; - font-weight: @font-weight-bold; - } - - .filter-badge-container { - width: 30px; - padding-right: 10px; - } - - .filter-badge-container + div { - width: 100%; - } -} diff --git a/superset-frontend/src/visualizations/FilterBox/FilterBox.test.jsx b/superset-frontend/src/visualizations/FilterBox/FilterBox.test.jsx index 66597fc69152f..e37a4bf1c69d7 100644 --- a/superset-frontend/src/visualizations/FilterBox/FilterBox.test.jsx +++ b/superset-frontend/src/visualizations/FilterBox/FilterBox.test.jsx @@ -17,14 +17,13 @@ * under the License. */ import React from 'react'; -import { shallow } from 'enzyme'; import { styledMount as mount } from 'spec/helpers/theming'; import FilterBox from 'src/visualizations/FilterBox/FilterBox'; import SelectControl from 'src/explore/components/controls/SelectControl'; describe('FilterBox', () => { it('should only add defined non-predefined options to filtersChoices', () => { - const wrapper = shallow( + const wrapper = mount( { origSelectedValues={{}} />, ); - const inst = wrapper.instance(); + const inst = wrapper.find('FilterBox').instance(); // choose a predefined value inst.setState({ selectedValues: { name: ['John'] } }); expect(inst.props.filtersChoices.name.length).toEqual(2); diff --git a/superset-frontend/src/visualizations/TimeTable/TimeTable.jsx b/superset-frontend/src/visualizations/TimeTable/TimeTable.jsx index d12022f74c60e..f9638575bed66 100644 --- a/superset-frontend/src/visualizations/TimeTable/TimeTable.jsx +++ b/superset-frontend/src/visualizations/TimeTable/TimeTable.jsx @@ -31,7 +31,6 @@ import sortNumericValues from 'src/utils/sortNumericValues'; import FormattedNumber from './FormattedNumber'; import SparklineCell from './SparklineCell'; -import './TimeTable.less'; const ACCESSIBLE_COLOR_BOUNDS = ['#ca0020', '#0571b0']; @@ -101,6 +100,7 @@ const defaultProps = { const TimeTableStyles = styled.div` height: ${props => props.height}px; + overflow: auto; th { z-index: 1; // to cover sparkline @@ -325,7 +325,11 @@ const TimeTable = ({ : []; return ( - + None: from superset.dashboards.commands.export import ExportDashboardsCommand from superset.models.dashboard import Dashboard - g.user = security_manager.find_user( # pylint: disable=assigning-non-slot - username="admin" - ) + # pylint: disable=assigning-non-slot + g.user = security_manager.find_user(username="admin") dashboard_ids = [id_ for (id_,) in db.session.query(Dashboard.id).all()] timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") @@ -110,9 +109,8 @@ def export_datasources(datasource_file: Optional[str] = None) -> None: from superset.connectors.sqla.models import SqlaTable from superset.datasets.commands.export import ExportDatasetsCommand - g.user = security_manager.find_user( # pylint: disable=assigning-non-slot - username="admin" - ) + # pylint: disable=assigning-non-slot + g.user = security_manager.find_user(username="admin") dataset_ids = [id_ for (id_,) in db.session.query(SqlaTable.id).all()] timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") @@ -153,9 +151,8 @@ def import_dashboards(path: str, username: Optional[str]) -> None: ) if username is not None: - g.user = security_manager.find_user( # pylint: disable=assigning-non-slot - username=username - ) + # pylint: disable=assigning-non-slot + g.user = security_manager.find_user(username=username) if is_zipfile(path): with ZipFile(path) as bundle: contents = get_contents_from_bundle(bundle) @@ -320,9 +317,8 @@ def import_dashboards(path: str, recursive: bool, username: str) -> None: elif path_object.exists() and recursive: files.extend(path_object.rglob("*.json")) if username is not None: - g.user = security_manager.find_user( # pylint: disable=assigning-non-slot - username=username - ) + # pylint: disable=assigning-non-slot + g.user = security_manager.find_user(username=username) contents = {} for path_ in files: with open(path_) as file: diff --git a/superset/columns/models.py b/superset/columns/models.py index fbe045e3d3925..bfee3de859819 100644 --- a/superset/columns/models.py +++ b/superset/columns/models.py @@ -23,7 +23,6 @@ These models are not fully implemented, and shouldn't be used yet. """ - import sqlalchemy as sa from flask_appbuilder import Model @@ -33,6 +32,8 @@ ImportExportMixin, ) +UNKOWN_TYPE = "UNKNOWN" + class Column( Model, @@ -52,51 +53,58 @@ class Column( id = sa.Column(sa.Integer, primary_key=True) + # Assuming the column is an aggregation, is it additive? Useful for determining which + # aggregations can be done on the metric. Eg, ``COUNT(DISTINCT user_id)`` is not + # additive, so it shouldn't be used in a ``SUM``. + is_additive = sa.Column(sa.Boolean, default=False) + + # Is this column an aggregation (metric)? + is_aggregation = sa.Column(sa.Boolean, default=False) + + is_filterable = sa.Column(sa.Boolean, nullable=False, default=True) + is_dimensional = sa.Column(sa.Boolean, nullable=False, default=False) + + # Is an increase desired? Useful for displaying the results of A/B tests, or setting + # up alerts. Eg, this is true for "revenue", but false for "latency". + is_increase_desired = sa.Column(sa.Boolean, default=True) + + # Column is managed externally and should be read-only inside Superset + is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) + + # Is this column a partition? Useful for scheduling queries and previewing the latest + # data. + is_partition = sa.Column(sa.Boolean, default=False) + + # Does the expression point directly to a physical column? + is_physical = sa.Column(sa.Boolean, default=True) + + # Is this a spatial column? This could be leveraged in the future for spatial + # visualizations. + is_spatial = sa.Column(sa.Boolean, default=False) + + # Is this a time column? Useful for plotting time series. + is_temporal = sa.Column(sa.Boolean, default=False) + # We use ``sa.Text`` for these attributes because (1) in modern databases the # performance is the same as ``VARCHAR``[1] and (2) because some table names can be # **really** long (eg, Google Sheets URLs). # # [1] https://www.postgresql.org/docs/9.1/datatype-character.html name = sa.Column(sa.Text) - type = sa.Column(sa.Text) + # Raw type as returned and used by db engine. + type = sa.Column(sa.Text, default=UNKOWN_TYPE) # Columns are defined by expressions. For tables, these are the actual columns names, # and should match the ``name`` attribute. For datasets, these can be any valid SQL # expression. If the SQL expression is an aggregation the column is a metric, # otherwise it's a computed column. expression = sa.Column(sa.Text) - - # Does the expression point directly to a physical column? - is_physical = sa.Column(sa.Boolean, default=True) + unit = sa.Column(sa.Text) # Additional metadata describing the column. description = sa.Column(sa.Text) warning_text = sa.Column(sa.Text) - unit = sa.Column(sa.Text) - - # Is this a time column? Useful for plotting time series. - is_temporal = sa.Column(sa.Boolean, default=False) - - # Is this a spatial column? This could be leveraged in the future for spatial - # visualizations. - is_spatial = sa.Column(sa.Boolean, default=False) - - # Is this column a partition? Useful for scheduling queries and previewing the latest - # data. - is_partition = sa.Column(sa.Boolean, default=False) - - # Is this column an aggregation (metric)? - is_aggregation = sa.Column(sa.Boolean, default=False) - - # Assuming the column is an aggregation, is it additive? Useful for determining which - # aggregations can be done on the metric. Eg, ``COUNT(DISTINCT user_id)`` is not - # additive, so it shouldn't be used in a ``SUM``. - is_additive = sa.Column(sa.Boolean, default=False) - - # Is an increase desired? Useful for displaying the results of A/B tests, or setting - # up alerts. Eg, this is true for "revenue", but false for "latency". - is_increase_desired = sa.Column(sa.Boolean, default=True) - - # Column is managed externally and should be read-only inside Superset - is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) external_url = sa.Column(sa.Text, nullable=True) + + def __repr__(self) -> str: + return f"" diff --git a/superset/common/query_actions.py b/superset/common/query_actions.py index 2b85125b0e98a..5899757d528d8 100644 --- a/superset/common/query_actions.py +++ b/superset/common/query_actions.py @@ -14,6 +14,8 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from __future__ import annotations + import copy from typing import Any, Callable, cast, Dict, List, Optional, TYPE_CHECKING @@ -41,13 +43,13 @@ def _get_datasource( - query_context: "QueryContext", query_obj: "QueryObject" + query_context: QueryContext, query_obj: QueryObject ) -> BaseDatasource: return query_obj.datasource or query_context.datasource def _get_columns( - query_context: "QueryContext", query_obj: "QueryObject", _: bool + query_context: QueryContext, query_obj: QueryObject, _: bool ) -> Dict[str, Any]: datasource = _get_datasource(query_context, query_obj) return { @@ -63,7 +65,7 @@ def _get_columns( def _get_timegrains( - query_context: "QueryContext", query_obj: "QueryObject", _: bool + query_context: QueryContext, query_obj: QueryObject, _: bool ) -> Dict[str, Any]: datasource = _get_datasource(query_context, query_obj) return { @@ -79,8 +81,8 @@ def _get_timegrains( def _get_query( - query_context: "QueryContext", - query_obj: "QueryObject", + query_context: QueryContext, + query_obj: QueryObject, _: bool, ) -> Dict[str, Any]: datasource = _get_datasource(query_context, query_obj) @@ -93,8 +95,8 @@ def _get_query( def _get_full( - query_context: "QueryContext", - query_obj: "QueryObject", + query_context: QueryContext, + query_obj: QueryObject, force_cached: Optional[bool] = False, ) -> Dict[str, Any]: datasource = _get_datasource(query_context, query_obj) @@ -140,7 +142,7 @@ def _get_full( def _get_samples( - query_context: "QueryContext", query_obj: "QueryObject", force_cached: bool = False + query_context: QueryContext, query_obj: QueryObject, force_cached: bool = False ) -> Dict[str, Any]: datasource = _get_datasource(query_context, query_obj) query_obj = copy.copy(query_obj) @@ -155,14 +157,14 @@ def _get_samples( def _get_results( - query_context: "QueryContext", query_obj: "QueryObject", force_cached: bool = False + query_context: QueryContext, query_obj: QueryObject, force_cached: bool = False ) -> Dict[str, Any]: payload = _get_full(query_context, query_obj, force_cached) return payload _result_type_functions: Dict[ - ChartDataResultType, Callable[["QueryContext", "QueryObject", bool], Dict[str, Any]] + ChartDataResultType, Callable[[QueryContext, QueryObject, bool], Dict[str, Any]] ] = { ChartDataResultType.COLUMNS: _get_columns, ChartDataResultType.TIMEGRAINS: _get_timegrains, @@ -179,8 +181,8 @@ def _get_results( def get_query_results( result_type: ChartDataResultType, - query_context: "QueryContext", - query_obj: "QueryObject", + query_context: QueryContext, + query_obj: QueryObject, force_cached: bool, ) -> Dict[str, Any]: """ diff --git a/superset/common/query_context_processor.py b/superset/common/query_context_processor.py index c87e878fddefe..003c174c2a170 100644 --- a/superset/common/query_context_processor.py +++ b/superset/common/query_context_processor.py @@ -52,7 +52,7 @@ get_column_names_from_metrics, get_metric_names, normalize_dttm_col, - TIME_COMPARISION, + TIME_COMPARISON, ) from superset.utils.date_parser import get_past_or_future, normalize_time_delta from superset.views.utils import get_viz @@ -289,7 +289,7 @@ def processing_time_offsets( # pylint: disable=too-many-locals query_object_clone_dct = query_object_clone.to_dict() # rename metrics: SUM(value) => SUM(value) 1 year ago metrics_mapping = { - metric: TIME_COMPARISION.join([metric, offset]) + metric: TIME_COMPARISON.join([metric, offset]) for metric in get_metric_names( query_object_clone_dct.get("metrics", []) ) diff --git a/superset/common/query_object.py b/superset/common/query_object.py index 78a76fc3cdee7..40d37041b9166 100644 --- a/superset/common/query_object.py +++ b/superset/common/query_object.py @@ -100,7 +100,7 @@ class QueryObject: # pylint: disable=too-many-instance-attributes orderby: List[OrderBy] post_processing: List[Dict[str, Any]] result_type: Optional[ChartDataResultType] - row_limit: int + row_limit: Optional[int] row_offset: int series_columns: List[Column] series_limit: int @@ -127,7 +127,7 @@ def __init__( # pylint: disable=too-many-locals order_desc: bool = True, orderby: Optional[List[OrderBy]] = None, post_processing: Optional[List[Optional[Dict[str, Any]]]] = None, - row_limit: int, + row_limit: Optional[int], row_offset: Optional[int] = None, series_columns: Optional[List[Column]] = None, series_limit: int = 0, diff --git a/superset/connectors/base/models.py b/superset/connectors/base/models.py index 9aacb0dc8c641..3d22857912f10 100644 --- a/superset/connectors/base/models.py +++ b/superset/connectors/base/models.py @@ -31,7 +31,7 @@ from superset.models.slice import Slice from superset.superset_typing import FilterValue, FilterValues, QueryObjectDict from superset.utils import core as utils -from superset.utils.core import GenericDataType +from superset.utils.core import GenericDataType, MediumText METRIC_FORM_DATA_PARAMS = [ "metric", @@ -586,7 +586,7 @@ class BaseColumn(AuditMixinNullable, ImportExportMixin): type = Column(Text) groupby = Column(Boolean, default=True) filterable = Column(Boolean, default=True) - description = Column(Text) + description = Column(MediumText()) is_dttm = None # [optional] Set this to support import/export functionality @@ -672,7 +672,7 @@ class BaseMetric(AuditMixinNullable, ImportExportMixin): metric_name = Column(String(255), nullable=False) verbose_name = Column(String(1024)) metric_type = Column(String(32)) - description = Column(Text) + description = Column(MediumText()) d3format = Column(String(128)) warning_text = Column(Text) diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 0600f3b10a582..e0382c659514c 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -24,6 +24,7 @@ from datetime import datetime, timedelta from typing import ( Any, + Callable, cast, Dict, Hashable, @@ -34,6 +35,7 @@ Type, Union, ) +from uuid import uuid4 import dateutil.parser import numpy as np @@ -72,13 +74,13 @@ from sqlalchemy.sql.selectable import Alias, TableClause from superset import app, db, is_feature_enabled, security_manager -from superset.columns.models import Column as NewColumn +from superset.columns.models import Column as NewColumn, UNKOWN_TYPE from superset.common.db_query_status import QueryStatus from superset.connectors.base.models import BaseColumn, BaseDatasource, BaseMetric from superset.connectors.sqla.utils import ( + find_cached_objects_in_session, get_physical_table_metadata, get_virtual_table_metadata, - load_or_create_tables, validate_adhoc_subquery, ) from superset.datasets.models import Dataset as NewDataset @@ -100,7 +102,12 @@ clone_model, QueryResult, ) -from superset.sql_parse import ParsedQuery, sanitize_clause +from superset.sql_parse import ( + extract_table_references, + ParsedQuery, + sanitize_clause, + Table as TableName, +) from superset.superset_typing import ( AdhocColumn, AdhocMetric, @@ -114,6 +121,7 @@ GenericDataType, get_column_name, is_adhoc_column, + MediumText, QueryObjectFilterClause, remove_duplicates, ) @@ -130,6 +138,7 @@ "sum", "doubleSum", } +ADDITIVE_METRIC_TYPES_LOWER = {op.lower() for op in ADDITIVE_METRIC_TYPES} class SqlaQuery(NamedTuple): @@ -215,13 +224,13 @@ class TableColumn(Model, BaseColumn, CertificationMixin): __tablename__ = "table_columns" __table_args__ = (UniqueConstraint("table_id", "column_name"),) table_id = Column(Integer, ForeignKey("tables.id")) - table = relationship( + table: "SqlaTable" = relationship( "SqlaTable", backref=backref("columns", cascade="all, delete-orphan"), foreign_keys=[table_id], ) is_dttm = Column(Boolean, default=False) - expression = Column(Text) + expression = Column(MediumText()) python_date_format = Column(String(255)) extra = Column(Text) @@ -335,6 +344,7 @@ def get_timestamp_expression( :param time_grain: Optional time grain, e.g. P1Y :param label: alias/label that column is expected to have + :param template_processor: template processor :return: A TimeExpression object wrapped in a Label if supported by db """ label = label or utils.DTTM_ALIAS @@ -416,6 +426,59 @@ def data(self) -> Dict[str, Any]: return attr_dict + def to_sl_column( + self, known_columns: Optional[Dict[str, NewColumn]] = None + ) -> NewColumn: + """Convert a TableColumn to NewColumn""" + column = known_columns.get(self.uuid) if known_columns else None + if not column: + column = NewColumn() + + extra_json = self.get_extra_dict() + for attr in { + "verbose_name", + "python_date_format", + }: + value = getattr(self, attr) + if value: + extra_json[attr] = value + + column.uuid = self.uuid + column.created_on = self.created_on + column.changed_on = self.changed_on + column.created_by = self.created_by + column.changed_by = self.changed_by + column.name = self.column_name + column.type = self.type or UNKOWN_TYPE + column.expression = self.expression or self.table.quote_identifier( + self.column_name + ) + column.description = self.description + column.is_aggregation = False + column.is_dimensional = self.groupby + column.is_filterable = self.filterable + column.is_increase_desired = True + column.is_managed_externally = self.table.is_managed_externally + column.is_partition = False + column.is_physical = not self.expression + column.is_spatial = False + column.is_temporal = self.is_dttm + column.extra_json = json.dumps(extra_json) if extra_json else None + column.external_url = self.table.external_url + + return column + + @staticmethod + def after_delete( # pylint: disable=unused-argument + mapper: Mapper, + connection: Connection, + target: "TableColumn", + ) -> None: + session = inspect(target).session + column = session.query(NewColumn).filter_by(uuid=target.uuid).one_or_none() + if column: + session.delete(column) + class SqlMetric(Model, BaseMetric, CertificationMixin): @@ -429,7 +492,7 @@ class SqlMetric(Model, BaseMetric, CertificationMixin): backref=backref("metrics", cascade="all, delete-orphan"), foreign_keys=[table_id], ) - expression = Column(Text, nullable=False) + expression = Column(MediumText(), nullable=False) extra = Column(Text) export_fields = [ @@ -478,6 +541,58 @@ def data(self) -> Dict[str, Any]: attr_dict.update(super().data) return attr_dict + def to_sl_column( + self, known_columns: Optional[Dict[str, NewColumn]] = None + ) -> NewColumn: + """Convert a SqlMetric to NewColumn. Find and update existing or + create a new one.""" + column = known_columns.get(self.uuid) if known_columns else None + if not column: + column = NewColumn() + + extra_json = self.get_extra_dict() + for attr in {"verbose_name", "metric_type", "d3format"}: + value = getattr(self, attr) + if value is not None: + extra_json[attr] = value + is_additive = ( + self.metric_type and self.metric_type.lower() in ADDITIVE_METRIC_TYPES_LOWER + ) + + column.uuid = self.uuid + column.name = self.metric_name + column.created_on = self.created_on + column.changed_on = self.changed_on + column.created_by = self.created_by + column.changed_by = self.changed_by + column.type = UNKOWN_TYPE + column.expression = self.expression + column.warning_text = self.warning_text + column.description = self.description + column.is_aggregation = True + column.is_additive = is_additive + column.is_filterable = False + column.is_increase_desired = True + column.is_managed_externally = self.table.is_managed_externally + column.is_partition = False + column.is_physical = False + column.is_spatial = False + column.extra_json = json.dumps(extra_json) if extra_json else None + column.external_url = self.table.external_url + + return column + + @staticmethod + def after_delete( # pylint: disable=unused-argument + mapper: Mapper, + connection: Connection, + target: "SqlMetric", + ) -> None: + session = inspect(target).session + column = session.query(NewColumn).filter_by(uuid=target.uuid).one_or_none() + if column: + session.delete(column) + sqlatable_user = Table( "sqlatable_user", @@ -488,6 +603,27 @@ def data(self) -> Dict[str, Any]: ) +def _process_sql_expression( + expression: Optional[str], + database_id: int, + schema: str, + template_processor: Optional[BaseTemplateProcessor], +) -> Optional[str]: + if template_processor and expression: + expression = template_processor.process_template(expression) + if expression: + expression = validate_adhoc_subquery( + expression, + database_id, + schema, + ) + try: + expression = sanitize_clause(expression) + except QueryClauseValidationException as ex: + raise QueryObjectValidationError(ex.message) from ex + return expression + + class SqlaTable(Model, BaseDatasource): # pylint: disable=too-many-public-methods """An ORM object for SqlAlchemy table references""" @@ -522,7 +658,7 @@ class SqlaTable(Model, BaseDatasource): # pylint: disable=too-many-public-metho foreign_keys=[database_id], ) schema = Column(String(255)) - sql = Column(Text) + sql = Column(MediumText()) is_sqllab_view = Column(Boolean, default=False) template_params = Column(Text) extra = Column(Text) @@ -687,8 +823,9 @@ def sql_url(self) -> str: return self.database.sql_url + "?table_name=" + str(self.table_name) def external_metadata(self) -> List[Dict[str, str]]: + # todo(yongjie): create a pysical table column type in seprated PR if self.sql: - return get_virtual_table_metadata(dataset=self) + return get_virtual_table_metadata(dataset=self) # type: ignore return get_physical_table_metadata( database=self.database, table_name=self.table_name, @@ -874,13 +1011,17 @@ def get_rendered_sql( return sql def adhoc_metric_to_sqla( - self, metric: AdhocMetric, columns_by_name: Dict[str, TableColumn] + self, + metric: AdhocMetric, + columns_by_name: Dict[str, TableColumn], + template_processor: Optional[BaseTemplateProcessor] = None, ) -> ColumnElement: """ Turn an adhoc metric into a sqlalchemy column. :param dict metric: Adhoc metric definition :param dict columns_by_name: Columns for the current table + :param template_processor: template_processor instance :returns: The metric defined as a sqlalchemy column :rtype: sqlalchemy.sql.column """ @@ -897,17 +1038,12 @@ def adhoc_metric_to_sqla( sqla_column = column(column_name) sqla_metric = self.sqla_aggregations[metric["aggregate"]](sqla_column) elif expression_type == utils.AdhocMetricExpressionType.SQL: - tp = self.get_template_processor() - expression = tp.process_template(cast(str, metric["sqlExpression"])) - expression = validate_adhoc_subquery( - expression, - self.database_id, - self.schema, + expression = _process_sql_expression( + expression=metric["sqlExpression"], + database_id=self.database_id, + schema=self.schema, + template_processor=template_processor, ) - try: - expression = sanitize_clause(expression) - except QueryClauseValidationException as ex: - raise QueryObjectValidationError(ex.message) from ex sqla_metric = literal_column(expression) else: raise QueryObjectValidationError("Adhoc metric expressionType is invalid") @@ -928,21 +1064,14 @@ def adhoc_column_to_sqla( :rtype: sqlalchemy.sql.column """ label = utils.get_column_name(col) - expression = col["sqlExpression"] - if template_processor and expression: - expression = template_processor.process_template(expression) - if expression: - expression = validate_adhoc_subquery( - expression, - self.database_id, - self.schema, - ) - try: - expression = sanitize_clause(expression) - except QueryClauseValidationException as ex: - raise QueryObjectValidationError(ex.message) from ex - sqla_metric = literal_column(expression) - return self.make_sqla_column_compatible(sqla_metric, label) + expression = _process_sql_expression( + expression=col["sqlExpression"], + database_id=self.database_id, + schema=self.schema, + template_processor=template_processor, + ) + sqla_column = literal_column(expression) + return self.make_sqla_column_compatible(sqla_column, label) def make_sqla_column_compatible( self, sqla_col: ColumnElement, label: Optional[str] = None @@ -1126,7 +1255,13 @@ def get_sqla_query( # pylint: disable=too-many-arguments,too-many-locals,too-ma for metric in metrics: if utils.is_adhoc_metric(metric): assert isinstance(metric, dict) - metrics_exprs.append(self.adhoc_metric_to_sqla(metric, columns_by_name)) + metrics_exprs.append( + self.adhoc_metric_to_sqla( + metric=metric, + columns_by_name=columns_by_name, + template_processor=template_processor, + ) + ) elif isinstance(metric, str) and metric in metrics_by_name: metrics_exprs.append(metrics_by_name[metric].get_sqla_col()) else: @@ -1153,10 +1288,11 @@ def get_sqla_query( # pylint: disable=too-many-arguments,too-many-locals,too-ma if isinstance(col, dict): col = cast(AdhocMetric, col) if col.get("sqlExpression"): - col["sqlExpression"] = validate_adhoc_subquery( - cast(str, col["sqlExpression"]), - self.database_id, - self.schema, + col["sqlExpression"] = _process_sql_expression( + expression=col["sqlExpression"], + database_id=self.database_id, + schema=self.schema, + template_processor=template_processor, ) if utils.is_adhoc_metric(col): # add adhoc sort by column to columns_by_name if not exists @@ -1709,7 +1845,19 @@ def fetch_metadata(self, commit: bool = True) -> MetadataResult: metrics = [] any_date_col = None db_engine_spec = self.db_engine_spec - old_columns = db.session.query(TableColumn).filter(TableColumn.table == self) + + # If no `self.id`, then this is a new table, no need to fetch columns + # from db. Passing in `self.id` to query will actually automatically + # generate a new id, which can be tricky during certain transactions. + old_columns = ( + ( + db.session.query(TableColumn) + .filter(TableColumn.table_id == self.id) + .all() + ) + if self.id + else self.columns + ) old_columns_by_name: Dict[str, TableColumn] = { col.column_name: col for col in old_columns @@ -1723,13 +1871,15 @@ def fetch_metadata(self, commit: bool = True) -> MetadataResult: ) # clear old columns before adding modified columns back - self.columns = [] + columns = [] for col in new_columns: old_column = old_columns_by_name.pop(col["name"], None) if not old_column: results.added.append(col["name"]) new_column = TableColumn( - column_name=col["name"], type=col["type"], table=self + column_name=col["name"], + type=col["type"], + table=self, ) new_column.is_dttm = new_column.is_temporal db_engine_spec.alter_new_orm_column(new_column) @@ -1741,12 +1891,14 @@ def fetch_metadata(self, commit: bool = True) -> MetadataResult: new_column.expression = "" new_column.groupby = True new_column.filterable = True - self.columns.append(new_column) + columns.append(new_column) if not any_date_col and new_column.is_temporal: any_date_col = col["name"] - self.columns.extend( - [col for col in old_columns_by_name.values() if col.expression] - ) + + # add back calculated (virtual) columns + columns.extend([col for col in old_columns if col.expression]) + self.columns = columns + metrics.append( SqlMetric( metric_name="count", @@ -1832,6 +1984,10 @@ class and any keys added via `ExtraCache`. extra_cache_keys += sqla_query.extra_cache_keys return extra_cache_keys + @property + def quote_identifier(self) -> Callable[[str], str]: + return self.database.quote_identifier + @staticmethod def before_update( mapper: Mapper, # pylint: disable=unused-argument @@ -1873,14 +2029,44 @@ def before_update( ): raise Exception(get_dataset_exist_error_msg(target.full_name)) + def get_sl_columns(self) -> List[NewColumn]: + """ + Convert `SqlaTable.columns` and `SqlaTable.metrics` to the new Column model + """ + session: Session = inspect(self).session + + uuids = set() + for column_or_metric in self.columns + self.metrics: + # pre-assign uuid after new columns or metrics are inserted so + # the related `NewColumn` can have a deterministic uuid, too + if not column_or_metric.uuid: + column_or_metric.uuid = uuid4() + else: + uuids.add(column_or_metric.uuid) + + # load existing columns from cached session states first + existing_columns = set( + find_cached_objects_in_session(session, NewColumn, uuids=uuids) + ) + for column in existing_columns: + uuids.remove(column.uuid) + + if uuids: + # load those not found from db + existing_columns |= set( + session.query(NewColumn).filter(NewColumn.uuid.in_(uuids)) + ) + + known_columns = {column.uuid: column for column in existing_columns} + return [ + item.to_sl_column(known_columns) for item in self.columns + self.metrics + ] + @staticmethod def update_table( # pylint: disable=unused-argument mapper: Mapper, connection: Connection, target: Union[SqlMetric, TableColumn] ) -> None: """ - Forces an update to the table's changed_on value when a metric or column on the - table is updated. This busts the cache key for all charts that use the table. - :param mapper: Unused. :param connection: Unused. :param target: The metric or column that was updated. @@ -1888,90 +2074,43 @@ def update_table( # pylint: disable=unused-argument inspector = inspect(target) session = inspector.session - # get DB-specific conditional quoter for expressions that point to columns or - # table names - database = ( - target.table.database - or session.query(Database).filter_by(id=target.database_id).one() - ) - engine = database.get_sqla_engine(schema=target.table.schema) - conditional_quote = engine.dialect.identifier_preparer.quote - + # Forces an update to the table's changed_on value when a metric or column on the + # table is updated. This busts the cache key for all charts that use the table. session.execute(update(SqlaTable).where(SqlaTable.id == target.table.id)) - dataset = ( - session.query(NewDataset) - .filter_by(sqlatable_id=target.table.id) - .one_or_none() - ) - - if not dataset: - # if dataset is not found create a new copy - # of the dataset instead of updating the existing - - SqlaTable.write_shadow_dataset(target.table, database, session) - return - - # update ``Column`` model as well - if isinstance(target, TableColumn): - columns = [ - column - for column in dataset.columns - if column.name == target.column_name - ] - if not columns: - return - - column = columns[0] - extra_json = json.loads(target.extra or "{}") - for attr in {"groupby", "filterable", "verbose_name", "python_date_format"}: - value = getattr(target, attr) - if value: - extra_json[attr] = value - - column.name = target.column_name - column.type = target.type or "Unknown" - column.expression = target.expression or conditional_quote( - target.column_name + # if table itself has changed, shadow-writing will happen in `after_udpate` anyway + if target.table not in session.dirty: + dataset: NewDataset = ( + session.query(NewDataset) + .filter_by(uuid=target.table.uuid) + .one_or_none() ) - column.description = target.description - column.is_temporal = target.is_dttm - column.is_physical = target.expression is None - column.extra_json = json.dumps(extra_json) if extra_json else None - - else: # SqlMetric - columns = [ - column - for column in dataset.columns - if column.name == target.metric_name - ] - if not columns: + # Update shadow dataset and columns + # did we find the dataset? + if not dataset: + # if dataset is not found create a new copy + target.table.write_shadow_dataset() return - column = columns[0] - extra_json = json.loads(target.extra or "{}") - for attr in {"verbose_name", "metric_type", "d3format"}: - value = getattr(target, attr) - if value: - extra_json[attr] = value - - is_additive = ( - target.metric_type - and target.metric_type.lower() in ADDITIVE_METRIC_TYPES + # update changed_on timestamp + session.execute(update(NewDataset).where(NewDataset.id == dataset.id)) + + # update `Column` model as well + session.add( + target.to_sl_column( + { + target.uuid: session.query(NewColumn) + .filter_by(uuid=target.uuid) + .one_or_none() + } + ) ) - column.name = target.metric_name - column.expression = target.expression - column.warning_text = target.warning_text - column.description = target.description - column.is_additive = is_additive - column.extra_json = json.dumps(extra_json) if extra_json else None - @staticmethod def after_insert( mapper: Mapper, connection: Connection, - target: "SqlaTable", + sqla_table: "SqlaTable", ) -> None: """ Shadow write the dataset to new models. @@ -1985,24 +2124,14 @@ def after_insert( For more context: https://github.com/apache/superset/issues/14909 """ - session = inspect(target).session - # set permissions - security_manager.set_perm(mapper, connection, target) - - # get DB-specific conditional quoter for expressions that point to columns or - # table names - database = ( - target.database - or session.query(Database).filter_by(id=target.database_id).one() - ) - - SqlaTable.write_shadow_dataset(target, database, session) + security_manager.set_perm(mapper, connection, sqla_table) + sqla_table.write_shadow_dataset() @staticmethod def after_delete( # pylint: disable=unused-argument mapper: Mapper, connection: Connection, - target: "SqlaTable", + sqla_table: "SqlaTable", ) -> None: """ Shadow write the dataset to new models. @@ -2016,18 +2145,18 @@ def after_delete( # pylint: disable=unused-argument For more context: https://github.com/apache/superset/issues/14909 """ - session = inspect(target).session + session = inspect(sqla_table).session dataset = ( - session.query(NewDataset).filter_by(sqlatable_id=target.id).one_or_none() + session.query(NewDataset).filter_by(uuid=sqla_table.uuid).one_or_none() ) if dataset: session.delete(dataset) @staticmethod - def after_update( # pylint: disable=too-many-branches, too-many-locals, too-many-statements + def after_update( mapper: Mapper, connection: Connection, - target: "SqlaTable", + sqla_table: "SqlaTable", ) -> None: """ Shadow write the dataset to new models. @@ -2041,172 +2170,76 @@ def after_update( # pylint: disable=too-many-branches, too-many-locals, too-man For more context: https://github.com/apache/superset/issues/14909 """ - inspector = inspect(target) + # set permissions + security_manager.set_perm(mapper, connection, sqla_table) + + inspector = inspect(sqla_table) session = inspector.session # double-check that ``UPDATE``s are actually pending (this method is called even # for instances that have no net changes to their column-based attributes) - if not session.is_modified(target, include_collections=True): + if not session.is_modified(sqla_table, include_collections=True): return - # set permissions - security_manager.set_perm(mapper, connection, target) - - dataset = ( - session.query(NewDataset).filter_by(sqlatable_id=target.id).one_or_none() + # find the dataset from the known instance list first + # (it could be either from a previous query or newly created) + dataset = next( + find_cached_objects_in_session( + session, NewDataset, uuids=[sqla_table.uuid] + ), + None, ) + # if not found, pull from database + if not dataset: + dataset = ( + session.query(NewDataset).filter_by(uuid=sqla_table.uuid).one_or_none() + ) if not dataset: + sqla_table.write_shadow_dataset() return - # get DB-specific conditional quoter for expressions that point to columns or - # table names - database = ( - target.database - or session.query(Database).filter_by(id=target.database_id).one() - ) - engine = database.get_sqla_engine(schema=target.schema) - conditional_quote = engine.dialect.identifier_preparer.quote - - # update columns - if inspector.attrs.columns.history.has_changes(): - # handle deleted columns - if inspector.attrs.columns.history.deleted: - column_names = { - column.column_name - for column in inspector.attrs.columns.history.deleted - } - dataset.columns = [ - column - for column in dataset.columns - if column.name not in column_names - ] - - # handle inserted columns - for column in inspector.attrs.columns.history.added: - # ``is_active`` might be ``None``, but it defaults to ``True``. - if column.is_active is False: - continue - - extra_json = json.loads(column.extra or "{}") - for attr in { - "groupby", - "filterable", - "verbose_name", - "python_date_format", - }: - value = getattr(column, attr) - if value: - extra_json[attr] = value - - dataset.columns.append( - NewColumn( - name=column.column_name, - type=column.type or "Unknown", - expression=column.expression - or conditional_quote(column.column_name), - description=column.description, - is_temporal=column.is_dttm, - is_aggregation=False, - is_physical=column.expression is None, - is_spatial=False, - is_partition=False, - is_increase_desired=True, - extra_json=json.dumps(extra_json) if extra_json else None, - is_managed_externally=target.is_managed_externally, - external_url=target.external_url, - ) - ) - - # update metrics - if inspector.attrs.metrics.history.has_changes(): - # handle deleted metrics - if inspector.attrs.metrics.history.deleted: - column_names = { - metric.metric_name - for metric in inspector.attrs.metrics.history.deleted - } - dataset.columns = [ - column - for column in dataset.columns - if column.name not in column_names - ] - - # handle inserted metrics - for metric in inspector.attrs.metrics.history.added: - extra_json = json.loads(metric.extra or "{}") - for attr in {"verbose_name", "metric_type", "d3format"}: - value = getattr(metric, attr) - if value: - extra_json[attr] = value - - is_additive = ( - metric.metric_type - and metric.metric_type.lower() in ADDITIVE_METRIC_TYPES - ) - - dataset.columns.append( - NewColumn( - name=metric.metric_name, - type="Unknown", - expression=metric.expression, - warning_text=metric.warning_text, - description=metric.description, - is_aggregation=True, - is_additive=is_additive, - is_physical=False, - is_spatial=False, - is_partition=False, - is_increase_desired=True, - extra_json=json.dumps(extra_json) if extra_json else None, - is_managed_externally=target.is_managed_externally, - external_url=target.external_url, - ) - ) + # sync column list and delete removed columns + if ( + inspector.attrs.columns.history.has_changes() + or inspector.attrs.metrics.history.has_changes() + ): + # add pending new columns to known columns list, too, so if calling + # `after_update` twice before changes are persisted will not create + # two duplicate columns with the same uuids. + dataset.columns = sqla_table.get_sl_columns() # physical dataset - if target.sql is None: - physical_columns = [ - column for column in dataset.columns if column.is_physical - ] - - # if the table name changed we should create a new table instance, instead - # of reusing the original one + if not sqla_table.sql: + # if the table name changed we should relink the dataset to another table + # (and create one if necessary) if ( inspector.attrs.table_name.history.has_changes() or inspector.attrs.schema.history.has_changes() - or inspector.attrs.database_id.history.has_changes() + or inspector.attrs.database.history.has_changes() ): - # does the dataset point to an existing table? - table = ( - session.query(NewTable) - .filter_by( - database_id=target.database_id, - schema=target.schema, - name=target.table_name, - ) - .first() + tables = NewTable.bulk_load_or_create( + sqla_table.database, + [TableName(schema=sqla_table.schema, table=sqla_table.table_name)], + sync_columns=False, + default_props=dict( + changed_by=sqla_table.changed_by, + created_by=sqla_table.created_by, + is_managed_externally=sqla_table.is_managed_externally, + external_url=sqla_table.external_url, + ), ) - if not table: - # create new columns + if not tables[0].id: + # dataset columns will only be assigned to newly created tables + # existing tables should manage column syncing in another process physical_columns = [ - clone_model(column, ignore=["uuid"]) - for column in physical_columns + clone_model( + column, ignore=["uuid"], keep_relations=["changed_by"] + ) + for column in dataset.columns + if column.is_physical ] - - # create new table - table = NewTable( - name=target.table_name, - schema=target.schema, - catalog=None, - database_id=target.database_id, - columns=physical_columns, - is_managed_externally=target.is_managed_externally, - external_url=target.external_url, - ) - dataset.tables = [table] - elif dataset.tables: - table = dataset.tables[0] - table.columns = physical_columns + tables[0].columns = physical_columns + dataset.tables = tables # virtual dataset else: @@ -2215,29 +2248,34 @@ def after_update( # pylint: disable=too-many-branches, too-many-locals, too-man column.is_physical = False # update referenced tables if SQL changed - if inspector.attrs.sql.history.has_changes(): - parsed = ParsedQuery(target.sql) - referenced_tables = parsed.tables - - predicate = or_( - *[ - and_( - NewTable.schema == (table.schema or target.schema), - NewTable.name == table.table, - ) - for table in referenced_tables - ] + if sqla_table.sql and inspector.attrs.sql.history.has_changes(): + referenced_tables = extract_table_references( + sqla_table.sql, sqla_table.database.get_dialect().name + ) + dataset.tables = NewTable.bulk_load_or_create( + sqla_table.database, + referenced_tables, + default_schema=sqla_table.schema, + # sync metadata is expensive, we'll do it in another process + # e.g. when users open a Table page + sync_columns=False, + default_props=dict( + changed_by=sqla_table.changed_by, + created_by=sqla_table.created_by, + is_managed_externally=sqla_table.is_managed_externally, + external_url=sqla_table.external_url, + ), ) - dataset.tables = session.query(NewTable).filter(predicate).all() # update other attributes - dataset.name = target.table_name - dataset.expression = target.sql or conditional_quote(target.table_name) - dataset.is_physical = target.sql is None + dataset.name = sqla_table.table_name + dataset.expression = sqla_table.sql or sqla_table.quote_identifier( + sqla_table.table_name + ) + dataset.is_physical = not sqla_table.sql - @staticmethod - def write_shadow_dataset( # pylint: disable=too-many-locals - dataset: "SqlaTable", database: Database, session: Session + def write_shadow_dataset( + self: "SqlaTable", ) -> None: """ Shadow write the dataset to new models. @@ -2251,95 +2289,57 @@ def write_shadow_dataset( # pylint: disable=too-many-locals For more context: https://github.com/apache/superset/issues/14909 """ - - engine = database.get_sqla_engine(schema=dataset.schema) - conditional_quote = engine.dialect.identifier_preparer.quote + session = inspect(self).session + # make sure database points to the right instance, in case only + # `table.database_id` is updated and the changes haven't been + # consolidated by SQLA + if self.database_id and ( + not self.database or self.database.id != self.database_id + ): + self.database = session.query(Database).filter_by(id=self.database_id).one() # create columns columns = [] - for column in dataset.columns: - # ``is_active`` might be ``None`` at this point, but it defaults to ``True``. - if column.is_active is False: - continue - - try: - extra_json = json.loads(column.extra or "{}") - except json.decoder.JSONDecodeError: - extra_json = {} - for attr in {"groupby", "filterable", "verbose_name", "python_date_format"}: - value = getattr(column, attr) - if value: - extra_json[attr] = value - - columns.append( - NewColumn( - name=column.column_name, - type=column.type or "Unknown", - expression=column.expression - or conditional_quote(column.column_name), - description=column.description, - is_temporal=column.is_dttm, - is_aggregation=False, - is_physical=column.expression is None, - is_spatial=False, - is_partition=False, - is_increase_desired=True, - extra_json=json.dumps(extra_json) if extra_json else None, - is_managed_externally=dataset.is_managed_externally, - external_url=dataset.external_url, - ), - ) - - # create metrics - for metric in dataset.metrics: - try: - extra_json = json.loads(metric.extra or "{}") - except json.decoder.JSONDecodeError: - extra_json = {} - for attr in {"verbose_name", "metric_type", "d3format"}: - value = getattr(metric, attr) - if value: - extra_json[attr] = value - - is_additive = ( - metric.metric_type - and metric.metric_type.lower() in ADDITIVE_METRIC_TYPES - ) - - columns.append( - NewColumn( - name=metric.metric_name, - type="Unknown", # figuring this out would require a type inferrer - expression=metric.expression, - warning_text=metric.warning_text, - description=metric.description, - is_aggregation=True, - is_additive=is_additive, - is_physical=False, - is_spatial=False, - is_partition=False, - is_increase_desired=True, - extra_json=json.dumps(extra_json) if extra_json else None, - is_managed_externally=dataset.is_managed_externally, - external_url=dataset.external_url, - ), - ) + for item in self.columns + self.metrics: + item.created_by = self.created_by + item.changed_by = self.changed_by + # on `SqlaTable.after_insert`` event, although the table itself + # already has a `uuid`, the associated columns will not. + # Here we pre-assign a uuid so they can still be matched to the new + # Column after creation. + if not item.uuid: + item.uuid = uuid4() + columns.append(item.to_sl_column()) # physical dataset - if not dataset.sql: - physical_columns = [column for column in columns if column.is_physical] - - # create table - table = NewTable( - name=dataset.table_name, - schema=dataset.schema, - catalog=None, # currently not supported - database_id=dataset.database_id, - columns=physical_columns, - is_managed_externally=dataset.is_managed_externally, - external_url=dataset.external_url, + if not self.sql: + # always create separate column entries for Dataset and Table + # so updating a dataset would not update columns in the related table + physical_columns = [ + clone_model( + column, + ignore=["uuid"], + # `created_by` will always be left empty because it'd always + # be created via some sort of automated system. + # But keep `changed_by` in case someone manually changes + # column attributes such as `is_dttm`. + keep_relations=["changed_by"], + ) + for column in columns + if column.is_physical + ] + tables = NewTable.bulk_load_or_create( + self.database, + [TableName(schema=self.schema, table=self.table_name)], + sync_columns=False, + default_props=dict( + created_by=self.created_by, + changed_by=self.changed_by, + is_managed_externally=self.is_managed_externally, + external_url=self.external_url, + ), ) - tables = [table] + tables[0].columns = physical_columns # virtual dataset else: @@ -2348,27 +2348,39 @@ def write_shadow_dataset( # pylint: disable=too-many-locals column.is_physical = False # find referenced tables - parsed = ParsedQuery(dataset.sql) - referenced_tables = parsed.tables - tables = load_or_create_tables( - session, - dataset.database_id, - dataset.schema, + referenced_tables = extract_table_references( + self.sql, self.database.get_dialect().name + ) + tables = NewTable.bulk_load_or_create( + self.database, referenced_tables, - conditional_quote, - engine, + default_schema=self.schema, + # syncing table columns can be slow so we are not doing it here + sync_columns=False, + default_props=dict( + created_by=self.created_by, + changed_by=self.changed_by, + is_managed_externally=self.is_managed_externally, + external_url=self.external_url, + ), ) # create the new dataset new_dataset = NewDataset( - sqlatable_id=dataset.id, - name=dataset.table_name, - expression=dataset.sql or conditional_quote(dataset.table_name), + uuid=self.uuid, + database_id=self.database_id, + created_on=self.created_on, + created_by=self.created_by, + changed_by=self.changed_by, + changed_on=self.changed_on, + owners=self.owners, + name=self.table_name, + expression=self.sql or self.quote_identifier(self.table_name), tables=tables, columns=columns, - is_physical=not dataset.sql, - is_managed_externally=dataset.is_managed_externally, - external_url=dataset.external_url, + is_physical=not self.sql, + is_managed_externally=self.is_managed_externally, + external_url=self.external_url, ) session.add(new_dataset) @@ -2378,7 +2390,9 @@ def write_shadow_dataset( # pylint: disable=too-many-locals sa.event.listen(SqlaTable, "after_delete", SqlaTable.after_delete) sa.event.listen(SqlaTable, "after_update", SqlaTable.after_update) sa.event.listen(SqlMetric, "after_update", SqlaTable.update_table) +sa.event.listen(SqlMetric, "after_delete", SqlMetric.after_delete) sa.event.listen(TableColumn, "after_update", SqlaTable.update_table) +sa.event.listen(TableColumn, "after_delete", TableColumn.after_delete) RLSFilterRoles = Table( "rls_filter_roles", diff --git a/superset/connectors/sqla/utils.py b/superset/connectors/sqla/utils.py index 766b74e57c004..1786c5bf17169 100644 --- a/superset/connectors/sqla/utils.py +++ b/superset/connectors/sqla/utils.py @@ -15,17 +15,28 @@ # specific language governing permissions and limitations # under the License. from contextlib import closing -from typing import Callable, Dict, List, Optional, Set, TYPE_CHECKING +from typing import ( + Any, + Callable, + Dict, + Iterable, + Iterator, + List, + Optional, + Type, + TYPE_CHECKING, + TypeVar, +) +from uuid import UUID import sqlparse from flask_babel import lazy_gettext as _ -from sqlalchemy import and_, inspect, or_ -from sqlalchemy.engine import Engine +from sqlalchemy.engine.url import URL as SqlaURL from sqlalchemy.exc import NoSuchTableError +from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy.orm import Session from sqlalchemy.sql.type_api import TypeEngine -from superset.columns.models import Column as NewColumn from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.exceptions import ( SupersetGenericDBErrorException, @@ -33,21 +44,19 @@ ) from superset.models.core import Database from superset.result_set import SupersetResultSet -from superset.sql_parse import has_table_query, insert_rls, ParsedQuery, Table -from superset.tables.models import Table as NewTable +from superset.sql_parse import has_table_query, insert_rls, ParsedQuery +from superset.superset_typing import ResultSetColumnType +from superset.utils.memoized import memoized if TYPE_CHECKING: from superset.connectors.sqla.models import SqlaTable -TEMPORAL_TYPES = {"DATETIME", "DATE", "TIME", "TIMEDELTA"} - - def get_physical_table_metadata( database: Database, table_name: str, schema_name: Optional[str] = None, -) -> List[Dict[str, str]]: +) -> List[Dict[str, Any]]: """Use SQLAlchemy inspector to get table metadata""" db_engine_spec = database.db_engine_spec db_dialect = database.get_dialect() @@ -84,14 +93,14 @@ def get_physical_table_metadata( col.update( { "type": "UNKNOWN", - "generic_type": None, + "type_generic": None, "is_dttm": None, } ) return cols -def get_virtual_table_metadata(dataset: "SqlaTable") -> List[Dict[str, str]]: +def get_virtual_table_metadata(dataset: "SqlaTable") -> List[ResultSetColumnType]: """Use SQLparser to get virtual dataset metadata""" if not dataset.sql: raise SupersetGenericDBErrorException( @@ -171,76 +180,38 @@ def validate_adhoc_subquery( return ";\n".join(str(statement) for statement in statements) -def load_or_create_tables( # pylint: disable=too-many-arguments +@memoized +def get_dialect_name(drivername: str) -> str: + return SqlaURL(drivername).get_dialect().name + + +@memoized +def get_identifier_quoter(drivername: str) -> Dict[str, Callable[[str], str]]: + return SqlaURL(drivername).get_dialect()().identifier_preparer.quote + + +DeclarativeModel = TypeVar("DeclarativeModel", bound=DeclarativeMeta) + + +def find_cached_objects_in_session( session: Session, - database_id: int, - default_schema: Optional[str], - tables: Set[Table], - conditional_quote: Callable[[str], str], - engine: Engine, -) -> List[NewTable]: - """ - Load or create new table model instances. + cls: Type[DeclarativeModel], + ids: Optional[Iterable[int]] = None, + uuids: Optional[Iterable[UUID]] = None, +) -> Iterator[DeclarativeModel]: + """Find known ORM instances in cached SQLA session states. + + :param session: a SQLA session + :param cls: a SQLA DeclarativeModel + :param ids: ids of the desired model instances (optional) + :param uuids: uuids of the desired instances, will be ignored if `ids` are provides """ - if not tables: - return [] - - # set the default schema in tables that don't have it - if default_schema: - fixed_tables = list(tables) - for i, table in enumerate(fixed_tables): - if table.schema is None: - fixed_tables[i] = Table(table.table, default_schema, table.catalog) - tables = set(fixed_tables) - - # load existing tables - predicate = or_( - *[ - and_( - NewTable.database_id == database_id, - NewTable.schema == table.schema, - NewTable.name == table.table, - ) - for table in tables - ] + if not ids and not uuids: + return iter([]) + uuids = uuids or [] + return ( + item + # `session` is an iterator of all known items + for item in set(session) + if isinstance(item, cls) and (item.id in ids if ids else item.uuid in uuids) ) - new_tables = session.query(NewTable).filter(predicate).all() - - # add missing tables - existing = {(table.schema, table.name) for table in new_tables} - for table in tables: - if (table.schema, table.table) not in existing: - try: - inspector = inspect(engine) - column_metadata = inspector.get_columns( - table.table, schema=table.schema - ) - except Exception: # pylint: disable=broad-except - continue - columns = [ - NewColumn( - name=column["name"], - type=str(column["type"]), - expression=conditional_quote(column["name"]), - is_temporal=column["type"].python_type.__name__.upper() - in TEMPORAL_TYPES, - is_aggregation=False, - is_physical=True, - is_spatial=False, - is_partition=False, - is_increase_desired=True, - ) - for column in column_metadata - ] - new_tables.append( - NewTable( - name=table.table, - schema=table.schema, - catalog=None, - database_id=database_id, - columns=columns, - ) - ) - existing.add((table.schema, table.table)) - - return new_tables diff --git a/superset/dao/base.py b/superset/dao/base.py index 607967e3041e2..0090c4e535e23 100644 --- a/superset/dao/base.py +++ b/superset/dao/base.py @@ -175,7 +175,7 @@ def update( def delete(cls, model: Model, commit: bool = True) -> Model: """ Generic delete a model - :raises: DAOCreateFailedError + :raises: DAODeleteFailedError """ try: db.session.delete(model) diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index d97b5f78e3c04..5e3f78a9536ae 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -58,6 +58,7 @@ from superset.dashboards.filters import ( DashboardAccessFilter, DashboardCertifiedFilter, + DashboardCreatedByMeFilter, DashboardFavoriteFilter, DashboardTitleOrSlugFilter, FilterRelatedRoles, @@ -166,6 +167,7 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: "changed_by_url", "changed_on_utc", "changed_on_delta_humanized", + "created_on_delta_humanized", "created_by.first_name", "created_by.id", "created_by.last_name", @@ -179,13 +181,14 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: "roles.name", "is_managed_externally", ] - list_select_columns = list_columns + ["changed_on", "changed_by_fk"] + list_select_columns = list_columns + ["changed_on", "created_on", "changed_by_fk"] order_columns = [ "changed_by.first_name", "changed_on_delta_humanized", "created_by.first_name", "dashboard_title", "published", + "changed_on", ] add_columns = [ @@ -215,6 +218,7 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: search_filters = { "dashboard_title": [DashboardTitleOrSlugFilter], "id": [DashboardFavoriteFilter, DashboardCertifiedFilter], + "created_by": [DashboardCreatedByMeFilter], } base_order = ("changed_on", "desc") @@ -226,7 +230,9 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: embedded_response_schema = EmbeddedDashboardResponseSchema() embedded_config_schema = EmbeddedDashboardConfigSchema() - base_filters = [["id", DashboardAccessFilter, lambda: []]] + base_filters = [ + ["id", DashboardAccessFilter, lambda: []], + ] order_rel_fields = { "slices": ("slice_name", "asc"), @@ -307,8 +313,6 @@ def get(self, dash: Dashboard) -> Response: properties: result: $ref: '#/components/schemas/DashboardGetResponseSchema' - 302: - description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: @@ -364,8 +368,6 @@ def get_datasets(self, id_or_slug: str) -> Response: type: array items: $ref: '#/components/schemas/DashboardDatasetSchema' - 302: - description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: @@ -427,8 +429,6 @@ def get_charts(self, id_or_slug: str) -> Response: type: array items: $ref: '#/components/schemas/ChartEntityResponseSchema' - 302: - description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: @@ -489,8 +489,6 @@ def post(self) -> Response: type: number result: $ref: '#/components/schemas/{{self.__class__.__name__}}.post' - 302: - description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: @@ -1192,5 +1190,6 @@ def delete_embedded(self, dashboard: Dashboard) -> Response: 500: $ref: '#/components/responses/500' """ - dashboard.embedded = [] + for embedded in dashboard.embedded: + DashboardDAO.delete(embedded) return self.response(200, message="OK") diff --git a/superset/dashboards/filters.py b/superset/dashboards/filters.py index 52a945ca41a62..3bbef14f4cb0e 100644 --- a/superset/dashboards/filters.py +++ b/superset/dashboards/filters.py @@ -49,6 +49,21 @@ def apply(self, query: Query, value: Any) -> Query: ) +class DashboardCreatedByMeFilter(BaseFilter): # pylint: disable=too-few-public-methods + name = _("Created by me") + arg_name = "created_by_me" + + def apply(self, query: Query, value: Any) -> Query: + return query.filter( + or_( + Dashboard.created_by_fk # pylint: disable=comparison-with-callable + == g.user.get_user_id(), + Dashboard.changed_by_fk # pylint: disable=comparison-with-callable + == g.user.get_user_id(), + ) + ) + + class DashboardFavoriteFilter( # pylint: disable=too-few-public-methods BaseFavoriteFilter ): diff --git a/superset/databases/api.py b/superset/databases/api.py index 0de8bcf83e9ba..ac497bf67dbde 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -51,7 +51,7 @@ from superset.databases.commands.validate import ValidateDatabaseParametersCommand from superset.databases.dao import DatabaseDAO from superset.databases.decorators import check_datasource_access -from superset.databases.filters import DatabaseFilter +from superset.databases.filters import DatabaseFilter, DatabaseUploadEnabledFilter from superset.databases.schemas import ( database_schemas_query_schema, DatabaseFunctionNamesResponse, @@ -166,8 +166,11 @@ class DatabaseRestApi(BaseSupersetModelRestApi): "encrypted_extra", "server_cert", ] + edit_columns = add_columns + search_filters = {"allow_file_upload": [DatabaseUploadEnabledFilter]} + list_select_columns = list_columns + ["extra", "sqlalchemy_uri", "password"] order_columns = [ "allow_file_upload", diff --git a/superset/databases/commands/test_connection.py b/superset/databases/commands/test_connection.py index 1155f4774db42..6fec767a1f52d 100644 --- a/superset/databases/commands/test_connection.py +++ b/superset/databases/commands/test_connection.py @@ -23,7 +23,6 @@ from flask_appbuilder.security.sqla.models import User from flask_babel import gettext as _ from func_timeout import func_timeout, FunctionTimedOut -from sqlalchemy.engine.url import make_url from sqlalchemy.exc import DBAPIError, NoSuchModuleError from superset.commands.base import BaseCommand @@ -34,6 +33,7 @@ DatabaseTestConnectionUnexpectedError, ) from superset.databases.dao import DatabaseDAO +from superset.databases.utils import make_url_safe from superset.errors import ErrorLevel, SupersetErrorType from superset.exceptions import SupersetSecurityException, SupersetTimeoutException from superset.extensions import event_logger @@ -55,7 +55,7 @@ def run(self) -> None: uri = self._model.sqlalchemy_uri_decrypted # context for error messages - url = make_url(uri) + url = make_url_safe(uri) context = { "hostname": url.host, "password": url.password, diff --git a/superset/databases/commands/validate.py b/superset/databases/commands/validate.py index 91e76d8d55efb..fa05dc7c3030c 100644 --- a/superset/databases/commands/validate.py +++ b/superset/databases/commands/validate.py @@ -20,7 +20,6 @@ from flask_appbuilder.security.sqla.models import User from flask_babel import gettext as __ -from sqlalchemy.engine.url import make_url from superset.commands.base import BaseCommand from superset.databases.commands.exceptions import ( @@ -30,6 +29,7 @@ InvalidParametersError, ) from superset.databases.dao import DatabaseDAO +from superset.databases.utils import make_url_safe from superset.db_engine_specs import get_engine_specs from superset.db_engine_specs.base import BasicParametersMixin from superset.errors import ErrorLevel, SupersetError, SupersetErrorType @@ -121,7 +121,7 @@ def run(self) -> None: with closing(engine.raw_connection()) as conn: alive = engine.dialect.do_ping(conn) except Exception as ex: - url = make_url(sqlalchemy_uri) + url = make_url_safe(sqlalchemy_uri) context = { "hostname": url.host, "password": url.password, diff --git a/superset/databases/filters.py b/superset/databases/filters.py index bd0729767ee4e..86564e8f15a7e 100644 --- a/superset/databases/filters.py +++ b/superset/databases/filters.py @@ -16,32 +16,37 @@ # under the License. from typing import Any, Set +from flask import g +from flask_babel import lazy_gettext as _ from sqlalchemy import or_ from sqlalchemy.orm import Query +from sqlalchemy.sql.expression import cast +from sqlalchemy.sql.sqltypes import JSON -from superset import security_manager +from superset import app, security_manager +from superset.models.core import Database from superset.views.base import BaseFilter -class DatabaseFilter(BaseFilter): - # TODO(bogdan): consider caching. +def can_access_databases( + view_menu_name: str, +) -> Set[str]: + return { + security_manager.unpack_database_and_schema(vm).database + for vm in security_manager.user_view_menu_names(view_menu_name) + } + - def can_access_databases( # noqa pylint: disable=no-self-use - self, - view_menu_name: str, - ) -> Set[str]: - return { - security_manager.unpack_database_and_schema(vm).database - for vm in security_manager.user_view_menu_names(view_menu_name) - } +class DatabaseFilter(BaseFilter): # pylint: disable=too-few-public-methods + # TODO(bogdan): consider caching. def apply(self, query: Query, value: Any) -> Query: if security_manager.can_access_all_databases(): return query database_perms = security_manager.user_view_menu_names("database_access") - schema_access_databases = self.can_access_databases("schema_access") + schema_access_databases = can_access_databases("schema_access") - datasource_access_databases = self.can_access_databases("datasource_access") + datasource_access_databases = can_access_databases("datasource_access") return query.filter( or_( @@ -51,3 +56,34 @@ def apply(self, query: Query, value: Any) -> Query: ), ) ) + + +class DatabaseUploadEnabledFilter(BaseFilter): # pylint: disable=too-few-public-methods + """ + Custom filter for the GET list that filters all databases based on allow_file_upload + """ + + name = _("Upload Enabled") + arg_name = "upload_is_enabled" + + def apply(self, query: Query, value: Any) -> Query: + filtered_query = query.filter(Database.allow_file_upload) + + datasource_access_databases = can_access_databases("datasource_access") + + if hasattr(g, "user"): + allowed_schemas = [ + app.config["ALLOWED_USER_CSV_SCHEMA_FUNC"](db, g.user) + for db in datasource_access_databases + ] + + if len(allowed_schemas): + return filtered_query + + return filtered_query.filter( + or_( + cast(Database.extra, JSON)["schemas_allowed_for_file_upload"] + is not None, + cast(Database.extra, JSON)["schemas_allowed_for_file_upload"] != [], + ) + ) diff --git a/superset/databases/schemas.py b/superset/databases/schemas.py index 4fa38415ef2fc..dd60e87d167f0 100644 --- a/superset/databases/schemas.py +++ b/superset/databases/schemas.py @@ -24,10 +24,10 @@ from marshmallow.validate import Length, ValidationError from marshmallow_enum import EnumField from sqlalchemy import MetaData -from sqlalchemy.engine.url import make_url -from sqlalchemy.exc import ArgumentError from superset import db +from superset.databases.commands.exceptions import DatabaseInvalidError +from superset.databases.utils import make_url_safe from superset.db_engine_specs import BaseEngineSpec, get_engine_specs from superset.exceptions import CertificateException, SupersetSecurityException from superset.models.core import ConfigurationMethod, Database, PASSWORD_MASK @@ -144,8 +144,8 @@ def sqlalchemy_uri_validator(value: str) -> str: Validate if it's a valid SQLAlchemy URI and refuse SQLLite by default """ try: - uri = make_url(value.strip()) - except (ArgumentError, AttributeError, ValueError) as ex: + uri = make_url_safe(value.strip()) + except DatabaseInvalidError as ex: raise ValidationError( [ _( @@ -649,7 +649,7 @@ def validate_password(self, data: Dict[str, Any], **kwargs: Any) -> None: return uri = data["sqlalchemy_uri"] - password = make_url(uri).password + password = make_url_safe(uri).password if password == PASSWORD_MASK and data.get("password") is None: raise ValidationError("Must provide a password for the database") diff --git a/superset/databases/utils.py b/superset/databases/utils.py index 4d475177f7211..9229bb8cbae84 100644 --- a/superset/databases/utils.py +++ b/superset/databases/utils.py @@ -14,16 +14,17 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union -from superset import app -from superset.models.core import Database +from sqlalchemy.engine.url import make_url, URL -custom_password_store = app.config["SQLALCHEMY_CUSTOM_PASSWORD_STORE"] +from superset.databases.commands.exceptions import DatabaseInvalidError def get_foreign_keys_metadata( - database: Database, table_name: str, schema_name: Optional[str] + database: Any, + table_name: str, + schema_name: Optional[str], ) -> List[Dict[str, Any]]: foreign_keys = database.get_foreign_keys(table_name, schema_name) for fk in foreign_keys: @@ -33,7 +34,7 @@ def get_foreign_keys_metadata( def get_indexes_metadata( - database: Database, table_name: str, schema_name: Optional[str] + database: Any, table_name: str, schema_name: Optional[str] ) -> List[Dict[str, Any]]: indexes = database.get_indexes(table_name, schema_name) for idx in indexes: @@ -51,7 +52,7 @@ def get_col_type(col: Dict[Any, Any]) -> str: def get_table_metadata( - database: Database, table_name: str, schema_name: Optional[str] + database: Any, table_name: str, schema_name: Optional[str] ) -> Dict[str, Any]: """ Get table metadata information, including type, pk, fks. @@ -101,3 +102,23 @@ def get_table_metadata( "indexes": keys, "comment": table_comment, } + + +def make_url_safe(raw_url: Union[str, URL]) -> URL: + """ + Wrapper for SQLAlchemy's make_url(), which tends to raise too detailed of + errors, which inevitably find their way into server logs. ArgumentErrors + tend to contain usernames and passwords, which makes them non-log-friendly + :param raw_url: + :return: + """ + + if isinstance(raw_url, str): + url = raw_url.strip() + try: + return make_url(url) # noqa + except Exception: + raise DatabaseInvalidError() # pylint: disable=raise-missing-from + + else: + return raw_url diff --git a/superset/datasets/models.py b/superset/datasets/models.py index 56a6fbf4000e3..b433709f2c779 100644 --- a/superset/datasets/models.py +++ b/superset/datasets/models.py @@ -28,9 +28,11 @@ import sqlalchemy as sa from flask_appbuilder import Model -from sqlalchemy.orm import relationship +from sqlalchemy.orm import backref, relationship +from superset import security_manager from superset.columns.models import Column +from superset.models.core import Database from superset.models.helpers import ( AuditMixinNullable, ExtraJSONMixin, @@ -38,18 +40,33 @@ ) from superset.tables.models import Table -column_association_table = sa.Table( +dataset_column_association_table = sa.Table( "sl_dataset_columns", Model.metadata, # pylint: disable=no-member - sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id")), - sa.Column("column_id", sa.ForeignKey("sl_columns.id")), + sa.Column( + "dataset_id", + sa.ForeignKey("sl_datasets.id"), + primary_key=True, + ), + sa.Column( + "column_id", + sa.ForeignKey("sl_columns.id"), + primary_key=True, + ), ) -table_association_table = sa.Table( +dataset_table_association_table = sa.Table( "sl_dataset_tables", Model.metadata, # pylint: disable=no-member - sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id")), - sa.Column("table_id", sa.ForeignKey("sl_tables.id")), + sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id"), primary_key=True), + sa.Column("table_id", sa.ForeignKey("sl_tables.id"), primary_key=True), +) + +dataset_user_association_table = sa.Table( + "sl_dataset_users", + Model.metadata, # pylint: disable=no-member + sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id"), primary_key=True), + sa.Column("user_id", sa.ForeignKey("ab_user.id"), primary_key=True), ) @@ -61,10 +78,34 @@ class Dataset(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin): __tablename__ = "sl_datasets" id = sa.Column(sa.Integer, primary_key=True) + database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False) + database: Database = relationship( + "Database", + backref=backref("datasets", cascade="all, delete-orphan"), + foreign_keys=[database_id], + ) + # The relationship between datasets and columns is 1:n, but we use a + # many-to-many association table to avoid adding two mutually exclusive + # columns(dataset_id and table_id) to Column + columns: List[Column] = relationship( + "Column", + secondary=dataset_column_association_table, + cascade="all, delete-orphan", + single_parent=True, + backref="datasets", + ) + owners = relationship( + security_manager.user_model, secondary=dataset_user_association_table + ) + tables: List[Table] = relationship( + "Table", secondary=dataset_table_association_table, backref="datasets" + ) + + # Does the dataset point directly to a ``Table``? + is_physical = sa.Column(sa.Boolean, default=False) - # A temporary column, used for shadow writing to the new model. Once the ``SqlaTable`` - # model has been deleted this column can be removed. - sqlatable_id = sa.Column(sa.Integer, nullable=True, unique=True) + # Column is managed externally and should be read-only inside Superset + is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) # We use ``sa.Text`` for these attributes because (1) in modern databases the # performance is the same as ``VARCHAR``[1] and (2) because some table names can be @@ -72,21 +113,8 @@ class Dataset(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin): # # [1] https://www.postgresql.org/docs/9.1/datatype-character.html name = sa.Column(sa.Text) - expression = sa.Column(sa.Text) - - # n:n relationship - tables: List[Table] = relationship("Table", secondary=table_association_table) - - # The relationship between datasets and columns is 1:n, but we use a many-to-many - # association to differentiate between the relationship between tables and columns. - columns: List[Column] = relationship( - "Column", secondary=column_association_table, cascade="all, delete" - ) - - # Does the dataset point directly to a ``Table``? - is_physical = sa.Column(sa.Boolean, default=False) - - # Column is managed externally and should be read-only inside Superset - is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) external_url = sa.Column(sa.Text, nullable=True) + + def __repr__(self) -> str: + return f"" diff --git a/superset/db_engine_specs/__init__.py b/superset/db_engine_specs/__init__.py index 9eeb87acfe0cd..4474f2a748216 100644 --- a/superset/db_engine_specs/__init__.py +++ b/superset/db_engine_specs/__init__.py @@ -116,6 +116,9 @@ def get_available_engine_specs() -> Dict[Type[BaseEngineSpec], Set[str]]: hasattr(attribute, "dialect") and inspect.isclass(attribute.dialect) and issubclass(attribute.dialect, DefaultDialect) + # adodbapi dialect is removed in SQLA 1.4 and doesn't implement the + # `dbapi` method, hence needs to be ignored to avoid logging a warning + and attribute.dialect.driver != "adodbapi" ): try: attribute.dialect.dbapi() diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py index 200c7c8eac83c..1393fcdac5915 100644 --- a/superset/db_engine_specs/base.py +++ b/superset/db_engine_specs/base.py @@ -48,7 +48,7 @@ from sqlalchemy.engine.base import Engine from sqlalchemy.engine.interfaces import Compiled, Dialect from sqlalchemy.engine.reflection import Inspector -from sqlalchemy.engine.url import make_url, URL +from sqlalchemy.engine.url import URL from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import Session from sqlalchemy.sql import quoted_name, text @@ -58,10 +58,12 @@ from typing_extensions import TypedDict from superset import security_manager, sql_parse +from superset.databases.utils import make_url_safe from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.models.sql_lab import Query from superset.models.sql_types.base import literal_dttm_type_factory from superset.sql_parse import ParsedQuery, Table +from superset.superset_typing import ResultSetColumnType from superset.utils import core as utils from superset.utils.core import ColumnSpec, GenericDataType from superset.utils.hashing import md5_sha_from_str @@ -566,8 +568,10 @@ def fetch_data( @classmethod def expand_data( - cls, columns: List[Dict[Any, Any]], data: List[Dict[Any, Any]] - ) -> Tuple[List[Dict[Any, Any]], List[Dict[Any, Any]], List[Dict[Any, Any]]]: + cls, columns: List[ResultSetColumnType], data: List[Dict[Any, Any]] + ) -> Tuple[ + List[ResultSetColumnType], List[Dict[Any, Any]], List[ResultSetColumnType] + ]: """ Some engines support expanding nested fields. See implementation in Presto spec for details. @@ -1630,7 +1634,7 @@ def build_sqlalchemy_uri( # pylint: disable=unused-argument def get_parameters_from_uri( # pylint: disable=unused-argument cls, uri: str, encrypted_extra: Optional[Dict[str, Any]] = None ) -> BasicParametersType: - url = make_url(uri) + url = make_url_safe(uri) query = { key: value for (key, value) in url.query.items() diff --git a/superset/db_engine_specs/bigquery.py b/superset/db_engine_specs/bigquery.py index 2c9f81b1bdde0..d844b342cee97 100644 --- a/superset/db_engine_specs/bigquery.py +++ b/superset/db_engine_specs/bigquery.py @@ -28,11 +28,11 @@ from marshmallow.exceptions import ValidationError from sqlalchemy import column from sqlalchemy.engine.base import Engine -from sqlalchemy.engine.url import make_url from sqlalchemy.sql import sqltypes from typing_extensions import TypedDict from superset.databases.schemas import encrypted_field_properties, EncryptedString +from superset.databases.utils import make_url_safe from superset.db_engine_specs.base import BaseEngineSpec from superset.db_engine_specs.exceptions import SupersetDBAPIDisconnectionError from superset.errors import SupersetError, SupersetErrorType @@ -377,7 +377,7 @@ def build_sqlalchemy_uri( def get_parameters_from_uri( cls, uri: str, encrypted_extra: Optional[Dict[str, str]] = None ) -> Any: - value = make_url(uri) + value = make_url_safe(uri) # Building parameters from encrypted_extra and uri if encrypted_extra: diff --git a/superset/db_engine_specs/hive.py b/superset/db_engine_specs/hive.py index 5f7e01d50271f..484e867ab0a88 100644 --- a/superset/db_engine_specs/hive.py +++ b/superset/db_engine_specs/hive.py @@ -31,11 +31,12 @@ from sqlalchemy import Column, text from sqlalchemy.engine.base import Engine from sqlalchemy.engine.reflection import Inspector -from sqlalchemy.engine.url import make_url, URL +from sqlalchemy.engine.url import URL from sqlalchemy.orm import Session from sqlalchemy.sql.expression import ColumnClause, Select from superset.common.db_query_status import QueryStatus +from superset.databases.utils import make_url_safe from superset.db_engine_specs.base import BaseEngineSpec from superset.db_engine_specs.presto import PrestoEngineSpec from superset.exceptions import SupersetException @@ -510,7 +511,7 @@ def update_impersonation_config( :param username: Effective username :return: None """ - url = make_url(uri) + url = make_url_safe(uri) backend_name = url.get_backend_name() # Must be Hive connection, enable impersonation, and set optional param diff --git a/superset/db_engine_specs/pinot.py b/superset/db_engine_specs/pinot.py index 051f42501f929..38e30accecbc0 100644 --- a/superset/db_engine_specs/pinot.py +++ b/superset/db_engine_specs/pinot.py @@ -33,6 +33,10 @@ class PinotEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method _time_grain_expressions: Dict[Optional[str], str] = { "PT1S": "1:SECONDS", "PT1M": "1:MINUTES", + "PT5M": "5:MINUTES", + "PT10M": "10:MINUTES", + "PT15M": "15:MINUTES", + "PT30M": "30:MINUTES", "PT1H": "1:HOURS", "P1D": "1:DAYS", "P1W": "week", @@ -53,6 +57,10 @@ class PinotEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method _use_date_trunc_function: Dict[str, bool] = { "PT1S": False, "PT1M": False, + "PT5M": False, + "PT10M": False, + "PT15M": False, + "PT30M": False, "PT1H": False, "P1D": False, "P1W": True, diff --git a/superset/db_engine_specs/presto.py b/superset/db_engine_specs/presto.py index 60cb9c7acaca6..8675607848328 100644 --- a/superset/db_engine_specs/presto.py +++ b/superset/db_engine_specs/presto.py @@ -34,12 +34,13 @@ from sqlalchemy.engine.base import Engine from sqlalchemy.engine.reflection import Inspector from sqlalchemy.engine.result import RowProxy -from sqlalchemy.engine.url import make_url, URL +from sqlalchemy.engine.url import URL from sqlalchemy.orm import Session from sqlalchemy.sql.expression import ColumnClause, Select from superset import cache_manager, is_feature_enabled from superset.common.db_query_status import QueryStatus +from superset.databases.utils import make_url_safe from superset.db_engine_specs.base import BaseEngineSpec, ColumnTypeMapping from superset.errors import SupersetErrorType from superset.exceptions import SupersetTemplateException @@ -53,6 +54,7 @@ ) from superset.result_set import destringify from superset.sql_parse import ParsedQuery +from superset.superset_typing import ResultSetColumnType from superset.utils import core as utils from superset.utils.core import ColumnSpec, GenericDataType @@ -85,24 +87,26 @@ logger = logging.getLogger(__name__) -def get_children(column: Dict[str, str]) -> List[Dict[str, str]]: +def get_children(column: ResultSetColumnType) -> List[ResultSetColumnType]: """ Get the children of a complex Presto type (row or array). For arrays, we return a single list with the base type: - >>> get_children(dict(name="a", type="ARRAY(BIGINT)")) - [{"name": "a", "type": "BIGINT"}] + >>> get_children(dict(name="a", type="ARRAY(BIGINT)", is_dttm=False)) + [{"name": "a", "type": "BIGINT", "is_dttm": False}] For rows, we return a list of the columns: - >>> get_children(dict(name="a", type="ROW(BIGINT,FOO VARCHAR)")) - [{'name': 'a._col0', 'type': 'BIGINT'}, {'name': 'a.foo', 'type': 'VARCHAR'}] + >>> get_children(dict(name="a", type="ROW(BIGINT,FOO VARCHAR)", is_dttm=False)) + [{'name': 'a._col0', 'type': 'BIGINT', 'is_dttm': False}, {'name': 'a.foo', 'type': 'VARCHAR', 'is_dttm': False}] # pylint: disable=line-too-long :param column: dictionary representing a Presto column :return: list of dictionaries representing children columns """ pattern = re.compile(r"(?P\w+)\((?P.*)\)") + if not column["type"]: + raise ValueError match = pattern.match(column["type"]) if not match: raise Exception(f"Unable to parse column type {column['type']}") @@ -111,7 +115,7 @@ def get_children(column: Dict[str, str]) -> List[Dict[str, str]]: type_ = group["type"].upper() children_type = group["children"] if type_ == "ARRAY": - return [{"name": column["name"], "type": children_type}] + return [{"name": column["name"], "type": children_type, "is_dttm": False}] if type_ == "ROW": nameless_columns = 0 @@ -125,7 +129,12 @@ def get_children(column: Dict[str, str]) -> List[Dict[str, str]]: name = f"_col{nameless_columns}" type_ = parts[0] nameless_columns += 1 - columns.append({"name": f"{column['name']}.{name.lower()}", "type": type_}) + _column: ResultSetColumnType = { + "name": f"{column['name']}.{name.lower()}", + "type": type_, + "is_dttm": False, + } + columns.append(_column) return columns raise Exception(f"Unknown type {type_}!") @@ -227,7 +236,7 @@ def update_impersonation_config( :param username: Effective username :return: None """ - url = make_url(uri) + url = make_url_safe(uri) backend_name = url.get_backend_name() # Must be Presto connection, enable impersonation, and set optional param @@ -779,8 +788,10 @@ def get_all_datasource_names( @classmethod def expand_data( # pylint: disable=too-many-locals - cls, columns: List[Dict[Any, Any]], data: List[Dict[Any, Any]] - ) -> Tuple[List[Dict[Any, Any]], List[Dict[Any, Any]], List[Dict[Any, Any]]]: + cls, columns: List[ResultSetColumnType], data: List[Dict[Any, Any]] + ) -> Tuple[ + List[ResultSetColumnType], List[Dict[Any, Any]], List[ResultSetColumnType] + ]: """ We do not immediately display rows and arrays clearly in the data grid. This method separates out nested fields and data values to help clearly display @@ -808,7 +819,7 @@ def expand_data( # pylint: disable=too-many-locals # process each column, unnesting ARRAY types and # expanding ROW types into new columns to_process = deque((column, 0) for column in columns) - all_columns: List[Dict[str, Any]] = [] + all_columns: List[ResultSetColumnType] = [] expanded_columns = [] current_array_level = None while to_process: @@ -828,7 +839,7 @@ def expand_data( # pylint: disable=too-many-locals name = column["name"] values: Optional[Union[str, List[Any]]] - if column["type"].startswith("ARRAY("): + if column["type"] and column["type"].startswith("ARRAY("): # keep processing array children; we append to the right so that # multiple nested arrays are processed breadth-first to_process.append((get_children(column)[0], level + 1)) @@ -862,7 +873,7 @@ def expand_data( # pylint: disable=too-many-locals i += 1 - if column["type"].startswith("ROW("): + if column["type"] and column["type"].startswith("ROW("): # expand columns; we append them to the left so they are added # immediately after the parent expanded = get_children(column) diff --git a/superset/db_engine_specs/snowflake.py b/superset/db_engine_specs/snowflake.py index 058ca89c6af29..cf645f8b74c24 100644 --- a/superset/db_engine_specs/snowflake.py +++ b/superset/db_engine_specs/snowflake.py @@ -24,9 +24,10 @@ from apispec.ext.marshmallow import MarshmallowPlugin from flask_babel import gettext as __ from marshmallow import fields, Schema -from sqlalchemy.engine.url import make_url, URL +from sqlalchemy.engine.url import URL from typing_extensions import TypedDict +from superset.databases.utils import make_url_safe from superset.db_engine_specs.postgres import PostgresBaseEngineSpec from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.models.sql_lab import Query @@ -220,7 +221,7 @@ def get_parameters_from_uri( Dict[str, str] ] = None, ) -> Any: - url = make_url(uri) + url = make_url_safe(uri) query = dict(url.query.items()) return { "username": url.username, diff --git a/superset/db_engine_specs/trino.py b/superset/db_engine_specs/trino.py index 31e9a0aa7b3a3..8ff4cfde59676 100644 --- a/superset/db_engine_specs/trino.py +++ b/superset/db_engine_specs/trino.py @@ -21,8 +21,9 @@ import simplejson as json from flask import current_app -from sqlalchemy.engine.url import make_url, URL +from sqlalchemy.engine.url import URL +from superset.databases.utils import make_url_safe from superset.db_engine_specs.base import BaseEngineSpec from superset.utils import core as utils @@ -107,7 +108,7 @@ def update_impersonation_config( :param username: Effective username :return: None """ - url = make_url(uri) + url = make_url_safe(uri) backend_name = url.get_backend_name() # Must be Trino connection, enable impersonation, and set optional param diff --git a/superset/embedded/api.py b/superset/embedded/api.py new file mode 100644 index 0000000000000..f7278d910a079 --- /dev/null +++ b/superset/embedded/api.py @@ -0,0 +1,105 @@ +# 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 typing import Optional + +from flask import Response +from flask_appbuilder.api import expose, protect, safe +from flask_appbuilder.hooks import before_request +from flask_appbuilder.models.sqla.interface import SQLAInterface + +from superset import is_feature_enabled +from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod +from superset.dashboards.schemas import EmbeddedDashboardResponseSchema +from superset.embedded.dao import EmbeddedDAO +from superset.embedded_dashboard.commands.exceptions import ( + EmbeddedDashboardNotFoundError, +) +from superset.extensions import event_logger +from superset.models.embedded_dashboard import EmbeddedDashboard +from superset.reports.logs.schemas import openapi_spec_methods_override +from superset.views.base_api import BaseSupersetModelRestApi, statsd_metrics + +logger = logging.getLogger(__name__) + + +class EmbeddedDashboardRestApi(BaseSupersetModelRestApi): + datamodel = SQLAInterface(EmbeddedDashboard) + + @before_request + def ensure_embedded_enabled(self) -> Optional[Response]: + if not is_feature_enabled("EMBEDDED_SUPERSET"): + return self.response_404() + return None + + include_route_methods = RouteMethod.GET + class_permission_name = "EmbeddedDashboard" + method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP + + resource_name = "embedded_dashboard" + allow_browser_login = True + + openapi_spec_tag = "Embedded Dashboard" + openapi_spec_methods = openapi_spec_methods_override + + embedded_response_schema = EmbeddedDashboardResponseSchema() + + @expose("/", methods=["GET"]) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_embedded", + log_to_statsd=False, + ) + # pylint: disable=arguments-differ, arguments-renamed) + def get(self, uuid: str) -> Response: + """Response + Returns the dashboard's embedded configuration + --- + get: + description: >- + Returns the dashboard's embedded configuration + parameters: + - in: path + schema: + type: string + name: uuid + description: The embedded configuration uuid + responses: + 200: + description: Result contains the embedded dashboard configuration + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/EmbeddedDashboardResponseSchema' + 401: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + try: + embedded = EmbeddedDAO.find_by_id(uuid) + if not embedded: + raise EmbeddedDashboardNotFoundError() + result = self.embedded_response_schema.dump(embedded) + return self.response(200, result=result) + except EmbeddedDashboardNotFoundError: + return self.response_404() diff --git a/superset/embedded_dashboard/commands/exceptions.py b/superset/embedded_dashboard/commands/exceptions.py new file mode 100644 index 0000000000000..e99dfa807cf49 --- /dev/null +++ b/superset/embedded_dashboard/commands/exceptions.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 typing import Optional + +from flask_babel import lazy_gettext as _ + +from superset.commands.exceptions import ForbiddenError, ObjectNotFoundError + + +class EmbeddedDashboardNotFoundError(ObjectNotFoundError): + def __init__( + self, + embedded_dashboard_uuid: Optional[str] = None, + exception: Optional[Exception] = None, + ) -> None: + super().__init__("EmbeddedDashboard", embedded_dashboard_uuid, exception) + + +class EmbeddedDashboardAccessDeniedError(ForbiddenError): + message = _("You don't have access to this embedded dashboard config.") diff --git a/superset/examples/birth_names.py b/superset/examples/birth_names.py index 1380958b2ad4a..8d7c02799dd57 100644 --- a/superset/examples/birth_names.py +++ b/superset/examples/birth_names.py @@ -135,23 +135,26 @@ def _set_table_metadata(datasource: SqlaTable, database: "Database") -> None: def _add_table_metrics(datasource: SqlaTable) -> None: - if not any(col.column_name == "num_california" for col in datasource.columns): + # By accessing the attribute first, we make sure `datasource.columns` and + # `datasource.metrics` are already loaded. Otherwise accessing them later + # may trigger an unnecessary and unexpected `after_update` event. + columns, metrics = datasource.columns, datasource.metrics + + if not any(col.column_name == "num_california" for col in columns): col_state = str(column("state").compile(db.engine)) col_num = str(column("num").compile(db.engine)) - datasource.columns.append( + columns.append( TableColumn( column_name="num_california", expression=f"CASE WHEN {col_state} = 'CA' THEN {col_num} ELSE 0 END", ) ) - if not any(col.metric_name == "sum__num" for col in datasource.metrics): + if not any(col.metric_name == "sum__num" for col in metrics): col = str(column("num").compile(db.engine)) - datasource.metrics.append( - SqlMetric(metric_name="sum__num", expression=f"SUM({col})") - ) + metrics.append(SqlMetric(metric_name="sum__num", expression=f"SUM({col})")) - for col in datasource.columns: + for col in columns: if col.column_name == "ds": col.is_dttm = True break diff --git a/superset/importexport/api.py b/superset/importexport/api.py index 156b4c21bd77f..c0021a8f88cd7 100644 --- a/superset/importexport/api.py +++ b/superset/importexport/api.py @@ -40,6 +40,7 @@ class ImportExportRestApi(BaseApi): resource_name = "assets" openapi_spec_tag = "Import/export" + allow_browser_login = True @expose("/export/", methods=["GET"]) @protect() diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index e600b0f2f5e7b..1bc6b0b82417d 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -141,6 +141,7 @@ def init_views(self) -> None: from superset.datasets.api import DatasetRestApi from superset.datasets.columns.api import DatasetColumnsRestApi from superset.datasets.metrics.api import DatasetMetricRestApi + from superset.embedded.api import EmbeddedDashboardRestApi from superset.embedded.view import EmbeddedView from superset.explore.form_data.api import ExploreFormDataRestApi from superset.explore.permalink.api import ExplorePermalinkRestApi @@ -151,7 +152,7 @@ def init_views(self) -> None: from superset.reports.logs.api import ReportExecutionLogRestApi from superset.security.api import SecurityRestApi from superset.views.access_requests import AccessRequestsModelView - from superset.views.alerts import AlertView + from superset.views.alerts import AlertView, ReportView from superset.views.annotations import ( AnnotationLayerModelView, AnnotationModelView, @@ -208,6 +209,7 @@ def init_views(self) -> None: appbuilder.add_api(DatasetRestApi) appbuilder.add_api(DatasetColumnsRestApi) appbuilder.add_api(DatasetMetricRestApi) + appbuilder.add_api(EmbeddedDashboardRestApi) appbuilder.add_api(ExploreFormDataRestApi) appbuilder.add_api(ExplorePermalinkRestApi) appbuilder.add_api(FilterSetRestApi) @@ -445,6 +447,7 @@ def init_views(self) -> None: and self.config["DRUID_METADATA_LINKS_ENABLED"] ), ) + appbuilder.add_view_no_menu(ReportView) appbuilder.add_link( "Refresh Druid Metadata", label=__("Refresh Druid Metadata"), diff --git a/superset/jinja_context.py b/superset/jinja_context.py index ab3aa5070ca66..e365b9a708ddb 100644 --- a/superset/jinja_context.py +++ b/superset/jinja_context.py @@ -401,6 +401,25 @@ def validate_template_context( return validate_context_types(context) +def where_in(values: List[Any], mark: str = "'") -> str: + """ + Given a list of values, build a parenthesis list suitable for an IN expression. + + >>> where_in([1, "b", 3]) + (1, 'b', 3) + + """ + + def quote(value: Any) -> str: + if isinstance(value, str): + value = value.replace(mark, mark * 2) + return f"{mark}{value}{mark}" + return str(value) + + joined_values = ", ".join(quote(value) for value in values) + return f"({joined_values})" + + class BaseTemplateProcessor: """ Base class for database-specific jinja context @@ -433,6 +452,9 @@ def __init__( self._env = SandboxedEnvironment(undefined=DebugUndefined) self.set_context(**kwargs) + # custom filters + self._env.filters["where_in"] = where_in + def set_context(self, **kwargs: Any) -> None: self._context.update(kwargs) self._context.update(context_addons()) diff --git a/superset/key_value/shared_entries.py b/superset/key_value/shared_entries.py index 5dda89a7b3163..5f4ded949808c 100644 --- a/superset/key_value/shared_entries.py +++ b/superset/key_value/shared_entries.py @@ -20,7 +20,6 @@ from superset.key_value.types import KeyValueResource, SharedKey from superset.key_value.utils import get_uuid_namespace, random_key -from superset.utils.memoized import memoized RESOURCE = KeyValueResource.APP NAMESPACE = get_uuid_namespace("") @@ -42,7 +41,6 @@ def set_shared_value(key: SharedKey, value: Any) -> None: CreateKeyValueCommand(resource=RESOURCE, value=value, key=uuid_key).run() -@memoized def get_permalink_salt(key: SharedKey) -> str: salt = get_shared_value(key) if salt is None: diff --git a/superset/migrations/shared/utils.py b/superset/migrations/shared/utils.py index bff25e05d137f..4b0c4e1440dd5 100644 --- a/superset/migrations/shared/utils.py +++ b/superset/migrations/shared/utils.py @@ -15,38 +15,22 @@ # specific language governing permissions and limitations # under the License. import logging -from typing import Any, Iterator, Optional, Set +import os +import time +from typing import Any +from uuid import uuid4 from alembic import op from sqlalchemy import engine_from_config +from sqlalchemy.dialects.mysql.base import MySQLDialect +from sqlalchemy.dialects.postgresql.base import PGDialect from sqlalchemy.engine import reflection from sqlalchemy.exc import NoSuchTableError -from sqloxide import parse_sql +from sqlalchemy.orm import Session -from superset.sql_parse import ParsedQuery, Table +logger = logging.getLogger(__name__) -logger = logging.getLogger("alembic") - - -# mapping between sqloxide and SQLAlchemy dialects -sqloxide_dialects = { - "ansi": {"trino", "trinonative", "presto"}, - "hive": {"hive", "databricks"}, - "ms": {"mssql"}, - "mysql": {"mysql"}, - "postgres": { - "cockroachdb", - "hana", - "netezza", - "postgres", - "postgresql", - "redshift", - "vertica", - }, - "snowflake": {"snowflake"}, - "sqlite": {"sqlite", "gsheets", "shillelagh"}, - "clickhouse": {"clickhouse"}, -} +DEFAULT_BATCH_SIZE = int(os.environ.get("BATCH_SIZE", 1000)) def table_has_column(table: str, column: str) -> bool: @@ -57,7 +41,6 @@ def table_has_column(table: str, column: str) -> bool: :param column: A column name :returns: True iff the column exists in the table """ - config = op.get_context().config engine = engine_from_config( config.get_section(config.config_ini_section), prefix="sqlalchemy." @@ -69,38 +52,44 @@ def table_has_column(table: str, column: str) -> bool: return False -def find_nodes_by_key(element: Any, target: str) -> Iterator[Any]: - """ - Find all nodes in a SQL tree matching a given key. - """ - if isinstance(element, list): - for child in element: - yield from find_nodes_by_key(child, target) - elif isinstance(element, dict): - for key, value in element.items(): - if key == target: - yield value - else: - yield from find_nodes_by_key(value, target) +uuid_by_dialect = { + MySQLDialect: "UNHEX(REPLACE(CONVERT(UUID() using utf8mb4), '-', ''))", + PGDialect: "uuid_in(md5(random()::text || clock_timestamp()::text)::cstring)", +} -def extract_table_references(sql_text: str, sqla_dialect: str) -> Set[Table]: - """ - Return all the dependencies from a SQL sql_text. - """ - dialect = "generic" - for dialect, sqla_dialects in sqloxide_dialects.items(): - if sqla_dialect in sqla_dialects: - break - try: - tree = parse_sql(sql_text, dialect=dialect) - except Exception: # pylint: disable=broad-except - logger.warning("Unable to parse query with sqloxide: %s", sql_text) - # fallback to sqlparse - parsed = ParsedQuery(sql_text) - return parsed.tables +def assign_uuids( + model: Any, session: Session, batch_size: int = DEFAULT_BATCH_SIZE +) -> None: + """Generate new UUIDs for all rows in a table""" + bind = op.get_bind() + table_name = model.__tablename__ + count = session.query(model).count() + # silently skip if the table is empty (suitable for db initialization) + if count == 0: + return + + start_time = time.time() + print(f"\nAdding uuids for `{table_name}`...") + # Use dialect specific native SQL queries if possible + for dialect, sql in uuid_by_dialect.items(): + if isinstance(bind.dialect, dialect): + op.execute( + f"UPDATE {dialect().identifier_preparer.quote(table_name)} SET uuid = {sql}" + ) + print(f"Done. Assigned {count} uuids in {time.time() - start_time:.3f}s.\n") + return + + # Othwewise Use Python uuid function + start = 0 + while start < count: + end = min(start + batch_size, count) + for obj in session.query(model)[start:end]: + obj.uuid = uuid4() + session.merge(obj) + session.commit() + if start + batch_size < count: + print(f" uuid assigned to {end} out of {count}\r", end="") + start += batch_size - return { - Table(*[part["value"] for part in table["name"][::-1]]) - for table in find_nodes_by_key(tree, "Table") - } + print(f"Done. Assigned {count} uuids in {time.time() - start_time:.3f}s.\n") diff --git a/superset/migrations/versions/07071313dd52_change_fetch_values_predicate_to_text.py b/superset/migrations/versions/07071313dd52_change_fetch_values_predicate_to_text.py index 320fb55a35243..ce90e37c8bd2e 100644 --- a/superset/migrations/versions/07071313dd52_change_fetch_values_predicate_to_text.py +++ b/superset/migrations/versions/07071313dd52_change_fetch_values_predicate_to_text.py @@ -30,9 +30,7 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy import and_, func, or_ -from sqlalchemy.dialects import postgresql -from sqlalchemy.sql.schema import Table +from sqlalchemy import func from superset import db from superset.connectors.sqla.models import SqlaTable diff --git a/superset/migrations/versions/181091c0ef16_add_extra_column_to_columns_model.py b/superset/migrations/versions/181091c0ef16_add_extra_column_to_columns_model.py index 6adeccf1c011c..8ed0f00598173 100644 --- a/superset/migrations/versions/181091c0ef16_add_extra_column_to_columns_model.py +++ b/superset/migrations/versions/181091c0ef16_add_extra_column_to_columns_model.py @@ -28,9 +28,6 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import postgresql - -from superset.utils.core import generic_find_constraint_name def upgrade(): diff --git a/superset/migrations/versions/19e978e1b9c3_add_report_format_to_report_schedule_.py b/superset/migrations/versions/19e978e1b9c3_add_report_format_to_report_schedule_.py index ab25b88c5e4d2..ff191d0e3b29f 100644 --- a/superset/migrations/versions/19e978e1b9c3_add_report_format_to_report_schedule_.py +++ b/superset/migrations/versions/19e978e1b9c3_add_report_format_to_report_schedule_.py @@ -28,7 +28,6 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import postgresql def upgrade(): diff --git a/superset/migrations/versions/2ed890b36b94_rm_time_range_endpoints_from_qc.py b/superset/migrations/versions/2ed890b36b94_rm_time_range_endpoints_from_qc.py index 34b833dabd200..e4e4718a41173 100644 --- a/superset/migrations/versions/2ed890b36b94_rm_time_range_endpoints_from_qc.py +++ b/superset/migrations/versions/2ed890b36b94_rm_time_range_endpoints_from_qc.py @@ -26,40 +26,9 @@ revision = "2ed890b36b94" down_revision = "58df9d617f14" -import json - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql -from sqlalchemy.ext.declarative import declarative_base - -from superset import db - -Base = declarative_base() - - -class Slice(Base): - __tablename__ = "slices" - id = sa.Column(sa.Integer, primary_key=True) - query_context = sa.Column(sa.Text) - def upgrade(): - bind = op.get_bind() - session = db.Session(bind=bind) - for slc in session.query(Slice): - if slc.query_context: - try: - query_context = json.loads(slc.query_context) - except json.decoder.JSONDecodeError: - continue - queries = query_context.get("queries") - for query in queries: - query.get("extras", {}).pop("time_range_endpoints", None) - slc.queries = json.dumps(queries) - - session.commit() - session.close() + pass def downgrade(): diff --git a/superset/migrations/versions/3ba29ecbaac5_change_datatype_of_type_in_basecolumn.py b/superset/migrations/versions/3ba29ecbaac5_change_datatype_of_type_in_basecolumn.py index 15f81488a310c..4f94a4bb9beac 100644 --- a/superset/migrations/versions/3ba29ecbaac5_change_datatype_of_type_in_basecolumn.py +++ b/superset/migrations/versions/3ba29ecbaac5_change_datatype_of_type_in_basecolumn.py @@ -28,7 +28,6 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import postgresql def upgrade(): diff --git a/superset/migrations/versions/620241d1153f_update_time_grain_sqla.py b/superset/migrations/versions/620241d1153f_update_time_grain_sqla.py index 560b6106f4921..97bea8f9d142e 100644 --- a/superset/migrations/versions/620241d1153f_update_time_grain_sqla.py +++ b/superset/migrations/versions/620241d1153f_update_time_grain_sqla.py @@ -30,10 +30,10 @@ from alembic import op from sqlalchemy import Column, ForeignKey, Integer, Text -from sqlalchemy.engine.url import make_url from sqlalchemy.ext.declarative import declarative_base from superset import db, db_engine_specs +from superset.databases.utils import make_url_safe from superset.utils.memoized import memoized Base = declarative_base() @@ -46,7 +46,7 @@ class Database(Base): sqlalchemy_uri = Column(Text) def grains(self): - url = make_url(self.sqlalchemy_uri) + url = make_url_safe(self.sqlalchemy_uri) backend = url.get_backend_name() db_engine_spec = db_engine_specs.engines.get( backend, db_engine_specs.BaseEngineSpec diff --git a/superset/migrations/versions/6d20ba9ecb33_add_last_saved_at_to_slice_model.py b/superset/migrations/versions/6d20ba9ecb33_add_last_saved_at_to_slice_model.py index 408da53118415..c149adbc518d2 100644 --- a/superset/migrations/versions/6d20ba9ecb33_add_last_saved_at_to_slice_model.py +++ b/superset/migrations/versions/6d20ba9ecb33_add_last_saved_at_to_slice_model.py @@ -28,7 +28,6 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import postgresql def upgrade(): diff --git a/superset/migrations/versions/73fd22e742ab_add_dynamic_plugins_py.py b/superset/migrations/versions/73fd22e742ab_add_dynamic_plugins_py.py index e4c2d0bc519ff..e2bbedcd347cf 100644 --- a/superset/migrations/versions/73fd22e742ab_add_dynamic_plugins_py.py +++ b/superset/migrations/versions/73fd22e742ab_add_dynamic_plugins_py.py @@ -28,7 +28,6 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import postgresql def upgrade(): diff --git a/superset/migrations/versions/8b841273bec3_sql_lab_models_database_constraint_updates.py b/superset/migrations/versions/8b841273bec3_sql_lab_models_database_constraint_updates.py new file mode 100644 index 0000000000000..a497cf80f4bc0 --- /dev/null +++ b/superset/migrations/versions/8b841273bec3_sql_lab_models_database_constraint_updates.py @@ -0,0 +1,138 @@ +# 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. +"""sql_lab_models_database_constraint_updates + +Revision ID: 8b841273bec3 +Revises: 2ed890b36b94 +Create Date: 2022-03-16 21:07:48.768425 + +""" + +# revision identifiers, used by Alembic. +revision = "8b841273bec3" +down_revision = "2ed890b36b94" + +import sqlalchemy as sa +from alembic import op + +from superset.utils.core import generic_find_fk_constraint_name + + +def upgrade(): + bind = op.get_bind() + insp = sa.engine.reflection.Inspector.from_engine(bind) + + with op.batch_alter_table("tab_state") as batch_op: + table_schema_id_constraint = generic_find_fk_constraint_name( + "tab_state", {"id"}, "dbs", insp + ) + if table_schema_id_constraint: + batch_op.drop_constraint( + table_schema_id_constraint, + type_="foreignkey", + ) + + table_schema_id_constraint = generic_find_fk_constraint_name( + "tab_state", {"client_id"}, "query", insp + ) + if table_schema_id_constraint: + batch_op.drop_constraint( + table_schema_id_constraint, + type_="foreignkey", + ) + + batch_op.create_foreign_key( + "tab_state_database_id_fkey", + "dbs", + ["database_id"], + ["id"], + ondelete="CASCADE", + ) + + batch_op.create_foreign_key( + "tab_state_latest_query_id_fkey", + "query", + ["latest_query_id"], + ["client_id"], + ondelete="SET NULL", + ) + + with op.batch_alter_table("table_schema") as batch_op: + table_schema_id_constraint = generic_find_fk_constraint_name( + "table_schema", {"id"}, "dbs", insp + ) + if table_schema_id_constraint: + batch_op.drop_constraint( + table_schema_id_constraint, + type_="foreignkey", + ) + + batch_op.create_foreign_key( + "table_schema_database_id_fkey", + "dbs", + ["database_id"], + ["id"], + ondelete="CASCADE", + ) + + +def downgrade(): + bind = op.get_bind() + insp = sa.engine.reflection.Inspector.from_engine(bind) + + with op.batch_alter_table("tab_state") as batch_op: + table_schema_id_constraint = generic_find_fk_constraint_name( + "tab_state", {"id"}, "dbs", insp + ) + if table_schema_id_constraint: + batch_op.drop_constraint( + table_schema_id_constraint, + type_="foreignkey", + ) + + table_schema_id_constraint = generic_find_fk_constraint_name( + "tab_state", {"client_id"}, "query", insp + ) + if table_schema_id_constraint: + batch_op.drop_constraint( + table_schema_id_constraint, + type_="foreignkey", + ) + + batch_op.create_foreign_key( + "tab_state_database_id_fkey", "dbs", ["database_id"], ["id"] + ) + batch_op.create_foreign_key( + "tab_state_latest_query_id_fkey", + "query", + ["latest_query_id"], + ["client_id"], + ) + + with op.batch_alter_table("table_schema") as batch_op: + table_schema_id_constraint = generic_find_fk_constraint_name( + "table_schema", {"id"}, "dbs", insp + ) + if table_schema_id_constraint: + batch_op.drop_constraint( + table_schema_id_constraint, + type_="foreignkey", + ) + + batch_op.create_foreign_key( + "table_schema_database_id_fkey", "dbs", ["database_id"], ["id"] + ) diff --git a/superset/migrations/versions/96e99fb176a0_add_import_mixing_to_saved_query.py b/superset/migrations/versions/96e99fb176a0_add_import_mixing_to_saved_query.py index 57d22aa089aa2..f93deb1d0c950 100644 --- a/superset/migrations/versions/96e99fb176a0_add_import_mixing_to_saved_query.py +++ b/superset/migrations/versions/96e99fb176a0_add_import_mixing_to_saved_query.py @@ -32,9 +32,7 @@ from sqlalchemy_utils import UUIDType from superset import db -from superset.migrations.versions.b56500de1855_add_uuid_column_to_import_mixin import ( - add_uuids, -) +from superset.migrations.shared.utils import assign_uuids # revision identifiers, used by Alembic. revision = "96e99fb176a0" @@ -75,7 +73,7 @@ def upgrade(): # Ignore column update errors so that we can run upgrade multiple times pass - add_uuids(SavedQuery, "saved_query", session) + assign_uuids(SavedQuery, session) try: # Add uniqueness constraint diff --git a/superset/migrations/versions/9d8a8d575284_.py b/superset/migrations/versions/9d8a8d575284_.py new file mode 100644 index 0000000000000..fbbfac231b0e8 --- /dev/null +++ b/superset/migrations/versions/9d8a8d575284_.py @@ -0,0 +1,38 @@ +# 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. +"""merge point + +Revision ID: 9d8a8d575284 +Revises: ('8b841273bec3', 'b0d0249074e4') +Create Date: 2022-04-06 14:10:40.433050 + +""" + +# revision identifiers, used by Alembic. +revision = "9d8a8d575284" +down_revision = ("8b841273bec3", "b0d0249074e4") + +import sqlalchemy as sa +from alembic import op + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/superset/migrations/versions/a9422eeaae74_new_dataset_models_take_2.py b/superset/migrations/versions/a9422eeaae74_new_dataset_models_take_2.py new file mode 100644 index 0000000000000..0ded98b93cd98 --- /dev/null +++ b/superset/migrations/versions/a9422eeaae74_new_dataset_models_take_2.py @@ -0,0 +1,905 @@ +# 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. +"""new_dataset_models_take_2 + +Revision ID: a9422eeaae74 +Revises: ad07e4fdbaba +Create Date: 2022-04-01 14:38:09.499483 + +""" + +# revision identifiers, used by Alembic. +revision = "a9422eeaae74" +down_revision = "ad07e4fdbaba" + +import json +import os +from datetime import datetime +from typing import List, Optional, Set, Type, Union +from uuid import uuid4 + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import select +from sqlalchemy.ext.declarative import declarative_base, declared_attr +from sqlalchemy.orm import backref, relationship, Session +from sqlalchemy.schema import UniqueConstraint +from sqlalchemy.sql import functions as func +from sqlalchemy.sql.expression import and_, or_ +from sqlalchemy_utils import UUIDType + +from superset import app, db +from superset.connectors.sqla.models import ADDITIVE_METRIC_TYPES_LOWER +from superset.connectors.sqla.utils import get_dialect_name, get_identifier_quoter +from superset.extensions import encrypted_field_factory +from superset.migrations.shared.utils import assign_uuids +from superset.sql_parse import extract_table_references, Table +from superset.utils.core import MediumText + +Base = declarative_base() +custom_password_store = app.config["SQLALCHEMY_CUSTOM_PASSWORD_STORE"] +DB_CONNECTION_MUTATOR = app.config["DB_CONNECTION_MUTATOR"] +SHOW_PROGRESS = os.environ.get("SHOW_PROGRESS") == "1" +UNKNOWN_TYPE = "UNKNOWN" + + +user_table = sa.Table( + "ab_user", Base.metadata, sa.Column("id", sa.Integer(), primary_key=True) +) + + +class UUIDMixin: + uuid = sa.Column( + UUIDType(binary=True), primary_key=False, unique=True, default=uuid4 + ) + + +class AuxiliaryColumnsMixin(UUIDMixin): + """ + Auxiliary columns, a combination of columns added by + AuditMixinNullable + ImportExportMixin + """ + + created_on = sa.Column(sa.DateTime, default=datetime.now, nullable=True) + changed_on = sa.Column( + sa.DateTime, default=datetime.now, onupdate=datetime.now, nullable=True + ) + + @declared_attr + def created_by_fk(cls): + return sa.Column(sa.Integer, sa.ForeignKey("ab_user.id"), nullable=True) + + @declared_attr + def changed_by_fk(cls): + return sa.Column(sa.Integer, sa.ForeignKey("ab_user.id"), nullable=True) + + +def insert_from_select( + target: Union[str, sa.Table, Type[Base]], source: sa.sql.expression.Select +) -> None: + """ + Execute INSERT FROM SELECT to copy data from a SELECT query to the target table. + """ + if isinstance(target, sa.Table): + target_table = target + elif hasattr(target, "__tablename__"): + target_table: sa.Table = Base.metadata.tables[target.__tablename__] + else: + target_table: sa.Table = Base.metadata.tables[target] + cols = [col.name for col in source.columns if col.name in target_table.columns] + query = target_table.insert().from_select(cols, source) + return op.execute(query) + + +class Database(Base): + + __tablename__ = "dbs" + __table_args__ = (UniqueConstraint("database_name"),) + + id = sa.Column(sa.Integer, primary_key=True) + database_name = sa.Column(sa.String(250), unique=True, nullable=False) + sqlalchemy_uri = sa.Column(sa.String(1024), nullable=False) + password = sa.Column(encrypted_field_factory.create(sa.String(1024))) + impersonate_user = sa.Column(sa.Boolean, default=False) + encrypted_extra = sa.Column(encrypted_field_factory.create(sa.Text), nullable=True) + extra = sa.Column(sa.Text) + server_cert = sa.Column(encrypted_field_factory.create(sa.Text), nullable=True) + + +class TableColumn(AuxiliaryColumnsMixin, Base): + + __tablename__ = "table_columns" + __table_args__ = (UniqueConstraint("table_id", "column_name"),) + + id = sa.Column(sa.Integer, primary_key=True) + table_id = sa.Column(sa.Integer, sa.ForeignKey("tables.id")) + is_active = sa.Column(sa.Boolean, default=True) + extra = sa.Column(sa.Text) + column_name = sa.Column(sa.String(255), nullable=False) + type = sa.Column(sa.String(32)) + expression = sa.Column(MediumText()) + description = sa.Column(MediumText()) + is_dttm = sa.Column(sa.Boolean, default=False) + filterable = sa.Column(sa.Boolean, default=True) + groupby = sa.Column(sa.Boolean, default=True) + verbose_name = sa.Column(sa.String(1024)) + python_date_format = sa.Column(sa.String(255)) + + +class SqlMetric(AuxiliaryColumnsMixin, Base): + + __tablename__ = "sql_metrics" + __table_args__ = (UniqueConstraint("table_id", "metric_name"),) + + id = sa.Column(sa.Integer, primary_key=True) + table_id = sa.Column(sa.Integer, sa.ForeignKey("tables.id")) + extra = sa.Column(sa.Text) + metric_type = sa.Column(sa.String(32)) + metric_name = sa.Column(sa.String(255), nullable=False) + expression = sa.Column(MediumText(), nullable=False) + warning_text = sa.Column(MediumText()) + description = sa.Column(MediumText()) + d3format = sa.Column(sa.String(128)) + verbose_name = sa.Column(sa.String(1024)) + + +sqlatable_user_table = sa.Table( + "sqlatable_user", + Base.metadata, + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("user_id", sa.Integer, sa.ForeignKey("ab_user.id")), + sa.Column("table_id", sa.Integer, sa.ForeignKey("tables.id")), +) + + +class SqlaTable(AuxiliaryColumnsMixin, Base): + + __tablename__ = "tables" + __table_args__ = (UniqueConstraint("database_id", "schema", "table_name"),) + + id = sa.Column(sa.Integer, primary_key=True) + extra = sa.Column(sa.Text) + database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False) + database: Database = relationship( + "Database", + backref=backref("tables", cascade="all, delete-orphan"), + foreign_keys=[database_id], + ) + schema = sa.Column(sa.String(255)) + table_name = sa.Column(sa.String(250), nullable=False) + sql = sa.Column(MediumText()) + is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) + external_url = sa.Column(sa.Text, nullable=True) + + +table_column_association_table = sa.Table( + "sl_table_columns", + Base.metadata, + sa.Column("table_id", sa.ForeignKey("sl_tables.id"), primary_key=True), + sa.Column("column_id", sa.ForeignKey("sl_columns.id"), primary_key=True), +) + +dataset_column_association_table = sa.Table( + "sl_dataset_columns", + Base.metadata, + sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id"), primary_key=True), + sa.Column("column_id", sa.ForeignKey("sl_columns.id"), primary_key=True), +) + +dataset_table_association_table = sa.Table( + "sl_dataset_tables", + Base.metadata, + sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id"), primary_key=True), + sa.Column("table_id", sa.ForeignKey("sl_tables.id"), primary_key=True), +) + +dataset_user_association_table = sa.Table( + "sl_dataset_users", + Base.metadata, + sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id"), primary_key=True), + sa.Column("user_id", sa.ForeignKey("ab_user.id"), primary_key=True), +) + + +class NewColumn(AuxiliaryColumnsMixin, Base): + + __tablename__ = "sl_columns" + + id = sa.Column(sa.Integer, primary_key=True) + # A temporary column to link physical columns with tables so we don't + # have to insert a record in the relationship table while creating new columns. + table_id = sa.Column(sa.Integer, nullable=True) + + is_aggregation = sa.Column(sa.Boolean, nullable=False, default=False) + is_additive = sa.Column(sa.Boolean, nullable=False, default=False) + is_dimensional = sa.Column(sa.Boolean, nullable=False, default=False) + is_filterable = sa.Column(sa.Boolean, nullable=False, default=True) + is_increase_desired = sa.Column(sa.Boolean, nullable=False, default=True) + is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) + is_partition = sa.Column(sa.Boolean, nullable=False, default=False) + is_physical = sa.Column(sa.Boolean, nullable=False, default=False) + is_temporal = sa.Column(sa.Boolean, nullable=False, default=False) + is_spatial = sa.Column(sa.Boolean, nullable=False, default=False) + + name = sa.Column(sa.Text) + type = sa.Column(sa.Text) + unit = sa.Column(sa.Text) + expression = sa.Column(MediumText()) + description = sa.Column(MediumText()) + warning_text = sa.Column(MediumText()) + external_url = sa.Column(sa.Text, nullable=True) + extra_json = sa.Column(MediumText(), default="{}") + + +class NewTable(AuxiliaryColumnsMixin, Base): + + __tablename__ = "sl_tables" + + id = sa.Column(sa.Integer, primary_key=True) + # A temporary column to keep the link between NewTable to SqlaTable + sqlatable_id = sa.Column(sa.Integer, primary_key=False, nullable=True, unique=True) + database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False) + is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) + catalog = sa.Column(sa.Text) + schema = sa.Column(sa.Text) + name = sa.Column(sa.Text) + external_url = sa.Column(sa.Text, nullable=True) + extra_json = sa.Column(MediumText(), default="{}") + database: Database = relationship( + "Database", + backref=backref("new_tables", cascade="all, delete-orphan"), + foreign_keys=[database_id], + ) + + +class NewDataset(Base, AuxiliaryColumnsMixin): + + __tablename__ = "sl_datasets" + + id = sa.Column(sa.Integer, primary_key=True) + database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False) + is_physical = sa.Column(sa.Boolean, default=False) + is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) + name = sa.Column(sa.Text) + expression = sa.Column(MediumText()) + external_url = sa.Column(sa.Text, nullable=True) + extra_json = sa.Column(MediumText(), default="{}") + + +def find_tables( + session: Session, + database_id: int, + default_schema: Optional[str], + tables: Set[Table], +) -> List[int]: + """ + Look for NewTable's of from a specific database + """ + if not tables: + return [] + + predicate = or_( + *[ + and_( + NewTable.database_id == database_id, + NewTable.schema == (table.schema or default_schema), + NewTable.name == table.table, + ) + for table in tables + ] + ) + return session.query(NewTable.id).filter(predicate).all() + + +# helper SQLA elements for easier querying +is_physical_table = or_(SqlaTable.sql.is_(None), SqlaTable.sql == "") +is_physical_column = or_(TableColumn.expression.is_(None), TableColumn.expression == "") + +# filtering out table columns with valid associated SqlTable +active_table_columns = sa.join( + TableColumn, + SqlaTable, + TableColumn.table_id == SqlaTable.id, +) +active_metrics = sa.join(SqlMetric, SqlaTable, SqlMetric.table_id == SqlaTable.id) + + +def copy_tables(session: Session) -> None: + """Copy Physical tables""" + count = session.query(SqlaTable).filter(is_physical_table).count() + if not count: + return + print(f">> Copy {count:,} physical tables to sl_tables...") + insert_from_select( + NewTable, + select( + [ + # Tables need different uuid than datasets, since they are different + # entities. When INSERT FROM SELECT, we must provide a value for `uuid`, + # otherwise it'd use the default generated on Python side, which + # will cause duplicate values. They will be replaced by `assign_uuids` later. + SqlaTable.uuid, + SqlaTable.id.label("sqlatable_id"), + SqlaTable.created_on, + SqlaTable.changed_on, + SqlaTable.created_by_fk, + SqlaTable.changed_by_fk, + SqlaTable.table_name.label("name"), + SqlaTable.schema, + SqlaTable.database_id, + SqlaTable.is_managed_externally, + SqlaTable.external_url, + ] + ) + # use an inner join to filter out only tables with valid database ids + .select_from( + sa.join(SqlaTable, Database, SqlaTable.database_id == Database.id) + ).where(is_physical_table), + ) + + +def copy_datasets(session: Session) -> None: + """Copy all datasets""" + count = session.query(SqlaTable).count() + if not count: + return + print(f">> Copy {count:,} SqlaTable to sl_datasets...") + insert_from_select( + NewDataset, + select( + [ + SqlaTable.uuid, + SqlaTable.created_on, + SqlaTable.changed_on, + SqlaTable.created_by_fk, + SqlaTable.changed_by_fk, + SqlaTable.database_id, + SqlaTable.table_name.label("name"), + func.coalesce(SqlaTable.sql, SqlaTable.table_name).label("expression"), + is_physical_table.label("is_physical"), + SqlaTable.is_managed_externally, + SqlaTable.external_url, + SqlaTable.extra.label("extra_json"), + ] + ), + ) + + print(" Copy dataset owners...") + insert_from_select( + dataset_user_association_table, + select( + [NewDataset.id.label("dataset_id"), sqlatable_user_table.c.user_id] + ).select_from( + sqlatable_user_table.join( + SqlaTable, SqlaTable.id == sqlatable_user_table.c.table_id + ).join(NewDataset, NewDataset.uuid == SqlaTable.uuid) + ), + ) + + print(" Link physical datasets with tables...") + insert_from_select( + dataset_table_association_table, + select( + [ + NewDataset.id.label("dataset_id"), + NewTable.id.label("table_id"), + ] + ).select_from( + sa.join(SqlaTable, NewTable, NewTable.sqlatable_id == SqlaTable.id).join( + NewDataset, NewDataset.uuid == SqlaTable.uuid + ) + ), + ) + + +def copy_columns(session: Session) -> None: + """Copy columns with active associated SqlTable""" + count = session.query(TableColumn).select_from(active_table_columns).count() + if not count: + return + print(f">> Copy {count:,} table columns to sl_columns...") + insert_from_select( + NewColumn, + select( + [ + TableColumn.uuid, + TableColumn.created_on, + TableColumn.changed_on, + TableColumn.created_by_fk, + TableColumn.changed_by_fk, + TableColumn.groupby.label("is_dimensional"), + TableColumn.filterable.label("is_filterable"), + TableColumn.column_name.label("name"), + TableColumn.description, + func.coalesce(TableColumn.expression, TableColumn.column_name).label( + "expression" + ), + sa.literal(False).label("is_aggregation"), + is_physical_column.label("is_physical"), + func.coalesce(TableColumn.is_dttm, False).label("is_temporal"), + func.coalesce(TableColumn.type, UNKNOWN_TYPE).label("type"), + TableColumn.extra.label("extra_json"), + ] + ).select_from(active_table_columns), + ) + + joined_columns_table = active_table_columns.join( + NewColumn, TableColumn.uuid == NewColumn.uuid + ) + print(" Link all columns to sl_datasets...") + insert_from_select( + dataset_column_association_table, + select( + [ + NewDataset.id.label("dataset_id"), + NewColumn.id.label("column_id"), + ], + ).select_from( + joined_columns_table.join(NewDataset, NewDataset.uuid == SqlaTable.uuid) + ), + ) + + +def copy_metrics(session: Session) -> None: + """Copy metrics as virtual columns""" + metrics_count = session.query(SqlMetric).select_from(active_metrics).count() + if not metrics_count: + return + + print(f">> Copy {metrics_count:,} metrics to sl_columns...") + insert_from_select( + NewColumn, + select( + [ + SqlMetric.uuid, + SqlMetric.created_on, + SqlMetric.changed_on, + SqlMetric.created_by_fk, + SqlMetric.changed_by_fk, + SqlMetric.metric_name.label("name"), + SqlMetric.expression, + SqlMetric.description, + sa.literal(UNKNOWN_TYPE).label("type"), + ( + func.coalesce( + sa.func.lower(SqlMetric.metric_type).in_( + ADDITIVE_METRIC_TYPES_LOWER + ), + sa.literal(False), + ).label("is_additive") + ), + sa.literal(True).label("is_aggregation"), + # metrics are by default not filterable + sa.literal(False).label("is_filterable"), + sa.literal(False).label("is_dimensional"), + sa.literal(False).label("is_physical"), + sa.literal(False).label("is_temporal"), + SqlMetric.extra.label("extra_json"), + SqlMetric.warning_text, + ] + ).select_from(active_metrics), + ) + + print(" Link metric columns to datasets...") + insert_from_select( + dataset_column_association_table, + select( + [ + NewDataset.id.label("dataset_id"), + NewColumn.id.label("column_id"), + ], + ).select_from( + active_metrics.join(NewDataset, NewDataset.uuid == SqlaTable.uuid).join( + NewColumn, NewColumn.uuid == SqlMetric.uuid + ) + ), + ) + + +def postprocess_datasets(session: Session) -> None: + """ + Postprocess datasets after insertion to + - Quote table names for physical datasets (if needed) + - Link referenced tables to virtual datasets + """ + total = session.query(SqlaTable).count() + if not total: + return + + offset = 0 + limit = 10000 + + joined_tables = sa.join( + NewDataset, + SqlaTable, + NewDataset.uuid == SqlaTable.uuid, + ).join( + Database, + Database.id == SqlaTable.database_id, + isouter=True, + ) + assert session.query(func.count()).select_from(joined_tables).scalar() == total + + print(f">> Run postprocessing on {total} datasets") + + update_count = 0 + + def print_update_count(): + if SHOW_PROGRESS: + print( + f" Will update {update_count} datasets" + " " * 20, + end="\r", + ) + + while offset < total: + print( + f" Process dataset {offset + 1}~{min(total, offset + limit)}..." + + " " * 30 + ) + for ( + database_id, + dataset_id, + expression, + extra, + is_physical, + schema, + sqlalchemy_uri, + ) in session.execute( + select( + [ + NewDataset.database_id, + NewDataset.id.label("dataset_id"), + NewDataset.expression, + SqlaTable.extra, + NewDataset.is_physical, + SqlaTable.schema, + Database.sqlalchemy_uri, + ] + ) + .select_from(joined_tables) + .offset(offset) + .limit(limit) + ): + drivername = (sqlalchemy_uri or "").split("://")[0] + updates = {} + updated = False + if is_physical and drivername: + quoted_expression = get_identifier_quoter(drivername)(expression) + if quoted_expression != expression: + updates["expression"] = quoted_expression + + # add schema name to `dataset.extra_json` so we don't have to join + # tables in order to use datasets + if schema: + try: + extra_json = json.loads(extra) if extra else {} + except json.decoder.JSONDecodeError: + extra_json = {} + extra_json["schema"] = schema + updates["extra_json"] = json.dumps(extra_json) + + if updates: + session.execute( + sa.update(NewDataset) + .where(NewDataset.id == dataset_id) + .values(**updates) + ) + updated = True + + if not is_physical and expression: + table_refrences = extract_table_references( + expression, get_dialect_name(drivername), show_warning=False + ) + found_tables = find_tables( + session, database_id, schema, table_refrences + ) + if found_tables: + op.bulk_insert( + dataset_table_association_table, + [ + {"dataset_id": dataset_id, "table_id": table.id} + for table in found_tables + ], + ) + updated = True + + if updated: + update_count += 1 + print_update_count() + + session.flush() + offset += limit + + if SHOW_PROGRESS: + print("") + + +def postprocess_columns(session: Session) -> None: + """ + At this step, we will + - Add engine specific quotes to `expression` of physical columns + - Tuck some extra metadata to `extra_json` + """ + total = session.query(NewColumn).count() + if not total: + return + + def get_joined_tables(offset, limit): + return ( + sa.join( + session.query(NewColumn) + .offset(offset) + .limit(limit) + .subquery("sl_columns"), + dataset_column_association_table, + dataset_column_association_table.c.column_id == NewColumn.id, + ) + .join( + NewDataset, + NewDataset.id == dataset_column_association_table.c.dataset_id, + ) + .join( + dataset_table_association_table, + # Join tables with physical datasets + and_( + NewDataset.is_physical, + dataset_table_association_table.c.dataset_id == NewDataset.id, + ), + isouter=True, + ) + .join(Database, Database.id == NewDataset.database_id) + .join( + TableColumn, + TableColumn.uuid == NewColumn.uuid, + isouter=True, + ) + .join( + SqlMetric, + SqlMetric.uuid == NewColumn.uuid, + isouter=True, + ) + ) + + offset = 0 + limit = 100000 + + print(f">> Run postprocessing on {total:,} columns") + + update_count = 0 + + def print_update_count(): + if SHOW_PROGRESS: + print( + f" Will update {update_count} columns" + " " * 20, + end="\r", + ) + + while offset < total: + query = ( + select( + # sorted alphabetically + [ + NewColumn.id.label("column_id"), + TableColumn.column_name, + NewColumn.changed_by_fk, + NewColumn.changed_on, + NewColumn.created_on, + NewColumn.description, + SqlMetric.d3format, + NewDataset.external_url, + NewColumn.extra_json, + NewColumn.is_dimensional, + NewColumn.is_filterable, + NewDataset.is_managed_externally, + NewColumn.is_physical, + SqlMetric.metric_type, + TableColumn.python_date_format, + Database.sqlalchemy_uri, + dataset_table_association_table.c.table_id, + func.coalesce( + TableColumn.verbose_name, SqlMetric.verbose_name + ).label("verbose_name"), + NewColumn.warning_text, + ] + ) + .select_from(get_joined_tables(offset, limit)) + .where( + # pre-filter to columns with potential updates + or_( + NewColumn.is_physical, + TableColumn.verbose_name.isnot(None), + TableColumn.verbose_name.isnot(None), + SqlMetric.verbose_name.isnot(None), + SqlMetric.d3format.isnot(None), + SqlMetric.metric_type.isnot(None), + ) + ) + ) + + start = offset + 1 + end = min(total, offset + limit) + count = session.query(func.count()).select_from(query).scalar() + print(f" [Column {start:,} to {end:,}] {count:,} may be updated") + + physical_columns = [] + + for ( + # sorted alphabetically + column_id, + column_name, + changed_by_fk, + changed_on, + created_on, + description, + d3format, + external_url, + extra_json, + is_dimensional, + is_filterable, + is_managed_externally, + is_physical, + metric_type, + python_date_format, + sqlalchemy_uri, + table_id, + verbose_name, + warning_text, + ) in session.execute(query): + try: + extra = json.loads(extra_json) if extra_json else {} + except json.decoder.JSONDecodeError: + extra = {} + updated_extra = {**extra} + updates = {} + + if is_managed_externally: + updates["is_managed_externally"] = True + if external_url: + updates["external_url"] = external_url + + # update extra json + for (key, val) in ( + { + "verbose_name": verbose_name, + "python_date_format": python_date_format, + "d3format": d3format, + "metric_type": metric_type, + } + ).items(): + # save the original val, including if it's `false` + if val is not None: + updated_extra[key] = val + + if updated_extra != extra: + updates["extra_json"] = json.dumps(updated_extra) + + # update expression for physical table columns + if is_physical: + if column_name and sqlalchemy_uri: + drivername = sqlalchemy_uri.split("://")[0] + if is_physical and drivername: + quoted_expression = get_identifier_quoter(drivername)( + column_name + ) + if quoted_expression != column_name: + updates["expression"] = quoted_expression + # duplicate physical columns for tables + physical_columns.append( + dict( + created_on=created_on, + changed_on=changed_on, + changed_by_fk=changed_by_fk, + description=description, + expression=updates.get("expression", column_name), + external_url=external_url, + extra_json=updates.get("extra_json", extra_json), + is_aggregation=False, + is_dimensional=is_dimensional, + is_filterable=is_filterable, + is_managed_externally=is_managed_externally, + is_physical=True, + name=column_name, + table_id=table_id, + warning_text=warning_text, + ) + ) + + if updates: + session.execute( + sa.update(NewColumn) + .where(NewColumn.id == column_id) + .values(**updates) + ) + update_count += 1 + print_update_count() + + if physical_columns: + op.bulk_insert(NewColumn.__table__, physical_columns) + + session.flush() + offset += limit + + if SHOW_PROGRESS: + print("") + + print(" Assign table column relations...") + insert_from_select( + table_column_association_table, + select([NewColumn.table_id, NewColumn.id.label("column_id")]) + .select_from(NewColumn) + .where(and_(NewColumn.is_physical, NewColumn.table_id.isnot(None))), + ) + + +new_tables: sa.Table = [ + NewTable.__table__, + NewDataset.__table__, + NewColumn.__table__, + table_column_association_table, + dataset_column_association_table, + dataset_table_association_table, + dataset_user_association_table, +] + + +def reset_postgres_id_sequence(table: str) -> None: + op.execute( + f""" + SELECT setval( + pg_get_serial_sequence('{table}', 'id'), + COALESCE(max(id) + 1, 1), + false + ) + FROM {table}; + """ + ) + + +def upgrade() -> None: + bind = op.get_bind() + session: Session = db.Session(bind=bind) + Base.metadata.drop_all(bind=bind, tables=new_tables) + Base.metadata.create_all(bind=bind, tables=new_tables) + + copy_tables(session) + copy_datasets(session) + copy_columns(session) + copy_metrics(session) + session.commit() + + postprocess_columns(session) + session.commit() + + postprocess_datasets(session) + session.commit() + + # Table were created with the same uuids are datasets. They should + # have different uuids as they are different entities. + print(">> Assign new UUIDs to tables...") + assign_uuids(NewTable, session) + + print(">> Drop intermediate columns...") + # These columns are are used during migration, as datasets are independent of tables once created, + # dataset columns also the same to table columns. + with op.batch_alter_table(NewTable.__tablename__) as batch_op: + batch_op.drop_column("sqlatable_id") + with op.batch_alter_table(NewColumn.__tablename__) as batch_op: + batch_op.drop_column("table_id") + + +def downgrade(): + Base.metadata.drop_all(bind=op.get_bind(), tables=new_tables) diff --git a/superset/migrations/versions/ab9a9d86e695_deprecate_time_range_endpoints.py b/superset/migrations/versions/ab9a9d86e695_deprecate_time_range_endpoints.py index 3f149d3ff0c2a..148804d2588df 100644 --- a/superset/migrations/versions/ab9a9d86e695_deprecate_time_range_endpoints.py +++ b/superset/migrations/versions/ab9a9d86e695_deprecate_time_range_endpoints.py @@ -21,38 +21,13 @@ Create Date: 2022-02-25 08:06:14.835094 """ -import json - -from alembic import op -from sqlalchemy import Column, Integer, Text -from sqlalchemy.ext.declarative import declarative_base - -from superset import db - # revision identifiers, used by Alembic. revision = "ab9a9d86e695" down_revision = "b5a422d8e252" -Base = declarative_base() - - -class Slice(Base): - __tablename__ = "slices" - id = Column(Integer, primary_key=True) - params = Column(Text) - def upgrade(): - bind = op.get_bind() - session = db.Session(bind=bind) - - for slc in session.query(Slice): - params = json.loads(slc.params or "{}") - params.pop("time_range_endpoints", None) - slc.params = json.dumps(params) - - session.commit() - session.close() + pass def downgrade(): diff --git a/superset/migrations/versions/abe27eaf93db_add_extra_config_column_to_alerts.py b/superset/migrations/versions/abe27eaf93db_add_extra_config_column_to_alerts.py index 5a20fc894a639..2bc22cc2cf280 100644 --- a/superset/migrations/versions/abe27eaf93db_add_extra_config_column_to_alerts.py +++ b/superset/migrations/versions/abe27eaf93db_add_extra_config_column_to_alerts.py @@ -17,14 +17,14 @@ """add_extra_config_column_to_alerts Revision ID: abe27eaf93db -Revises: aea15018d53b +Revises: 0ca9e5f1dacd Create Date: 2021-12-02 12:03:20.691171 """ # revision identifiers, used by Alembic. revision = "abe27eaf93db" -down_revision = "aea15018d53b" +down_revision = "0ca9e5f1dacd" import sqlalchemy as sa from alembic import op diff --git a/superset/migrations/versions/ad07e4fdbaba_rm_time_range_endpoints_from_qc_3.py b/superset/migrations/versions/ad07e4fdbaba_rm_time_range_endpoints_from_qc_3.py new file mode 100644 index 0000000000000..30efb1a083fc2 --- /dev/null +++ b/superset/migrations/versions/ad07e4fdbaba_rm_time_range_endpoints_from_qc_3.py @@ -0,0 +1,84 @@ +# 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. +"""rm_time_range_endpoints_from_qc_3 + +Revision ID: ad07e4fdbaba +Revises: cecc6bf46990 +Create Date: 2022-04-18 11:20:47.390901 + +""" + +# revision identifiers, used by Alembic. +revision = "ad07e4fdbaba" +down_revision = "cecc6bf46990" + +import json + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.ext.declarative import declarative_base + +from superset import db + +Base = declarative_base() + + +class Slice(Base): + __tablename__ = "slices" + id = sa.Column(sa.Integer, primary_key=True) + query_context = sa.Column(sa.Text) + slice_name = sa.Column(sa.String(250)) + + +def upgrade_slice(slc: Slice): + try: + query_context = json.loads(slc.query_context) + except json.decoder.JSONDecodeError: + return + + query_context.get("form_data", {}).pop("time_range_endpoints", None) + + if query_context.get("queries"): + queries = query_context["queries"] + for query in queries: + query.get("extras", {}).pop("time_range_endpoints", None) + + slc.query_context = json.dumps(query_context) + + return slc + + +def upgrade(): + bind = op.get_bind() + session = db.Session(bind=bind) + slices_updated = 0 + for slc in ( + session.query(Slice) + .filter(Slice.query_context.like("%time_range_endpoints%")) + .all() + ): + updated_slice = upgrade_slice(slc) + if updated_slice: + slices_updated += 1 + + print(f"slices updated with no time_range_endpoints: {slices_updated}") + session.commit() + session.close() + + +def downgrade(): + pass diff --git a/superset/migrations/versions/b0d0249074e4_deprecate_time_range_endpoints_v2.py b/superset/migrations/versions/b0d0249074e4_deprecate_time_range_endpoints_v2.py new file mode 100644 index 0000000000000..90ee62d3f70d9 --- /dev/null +++ b/superset/migrations/versions/b0d0249074e4_deprecate_time_range_endpoints_v2.py @@ -0,0 +1,59 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""deprecate time_range_endpoints v2 + +Revision ID: b0d0249074e4 +Revises: 2ed890b36b94 +Create Date: 2022-04-04 15:04:05.606340 + +""" +import json + +from alembic import op +from sqlalchemy import Column, Integer, Text +from sqlalchemy.ext.declarative import declarative_base + +from superset import db + +# revision identifiers, used by Alembic. +revision = "b0d0249074e4" +down_revision = "2ed890b36b94" + +Base = declarative_base() + + +class Slice(Base): + __tablename__ = "slices" + id = Column(Integer, primary_key=True) + params = Column(Text) + + +def upgrade(): + bind = op.get_bind() + session = db.Session(bind=bind) + + for slc in session.query(Slice).filter(Slice.params.like("%time_range_endpoints%")): + params = json.loads(slc.params) + params.pop("time_range_endpoints", None) + slc.params = json.dumps(params) + + session.commit() + session.close() + + +def downgrade(): + pass diff --git a/superset/migrations/versions/b56500de1855_add_uuid_column_to_import_mixin.py b/superset/migrations/versions/b56500de1855_add_uuid_column_to_import_mixin.py index 747ec9fb4f77f..0872cf5b3bb5d 100644 --- a/superset/migrations/versions/b56500de1855_add_uuid_column_to_import_mixin.py +++ b/superset/migrations/versions/b56500de1855_add_uuid_column_to_import_mixin.py @@ -23,19 +23,17 @@ """ import json import os -import time from json.decoder import JSONDecodeError from uuid import uuid4 import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects.mysql.base import MySQLDialect -from sqlalchemy.dialects.postgresql.base import PGDialect from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import load_only from sqlalchemy_utils import UUIDType from superset import db +from superset.migrations.shared.utils import assign_uuids from superset.utils import core as utils # revision identifiers, used by Alembic. @@ -78,47 +76,6 @@ class ImportMixin: default_batch_size = int(os.environ.get("BATCH_SIZE", 200)) -# Add uuids directly using built-in SQL uuid function -add_uuids_by_dialect = { - MySQLDialect: """UPDATE %s SET uuid = UNHEX(REPLACE(CONVERT(UUID() using utf8mb4), '-', ''));""", - PGDialect: """UPDATE %s SET uuid = uuid_in(md5(random()::text || clock_timestamp()::text)::cstring);""", -} - - -def add_uuids(model, table_name, session, batch_size=default_batch_size): - """Populate columns with pre-computed uuids""" - bind = op.get_bind() - objects_query = session.query(model) - count = objects_query.count() - - # silently skip if the table is empty (suitable for db initialization) - if count == 0: - return - - print(f"\nAdding uuids for `{table_name}`...") - start_time = time.time() - - # Use dialect specific native SQL queries if possible - for dialect, sql in add_uuids_by_dialect.items(): - if isinstance(bind.dialect, dialect): - op.execute(sql % table_name) - print(f"Done. Assigned {count} uuids in {time.time() - start_time:.3f}s.") - return - - # Othwewise Use Python uuid function - start = 0 - while start < count: - end = min(start + batch_size, count) - for obj, uuid in map(lambda obj: (obj, uuid4()), objects_query[start:end]): - obj.uuid = uuid - session.merge(obj) - session.commit() - if start + batch_size < count: - print(f" uuid assigned to {end} out of {count}\r", end="") - start += batch_size - - print(f"Done. Assigned {count} uuids in {time.time() - start_time:.3f}s.") - def update_position_json(dashboard, session, uuid_map): try: @@ -178,7 +135,7 @@ def upgrade(): ), ) - add_uuids(model, table_name, session) + assign_uuids(model, session) # add uniqueness constraint with op.batch_alter_table(table_name) as batch_op: @@ -203,7 +160,7 @@ def downgrade(): update_dashboards(session, {}) # remove uuid column - for table_name, model in models.items(): + for table_name in models: with op.batch_alter_table(table_name) as batch_op: batch_op.drop_constraint(f"uq_{table_name}_uuid", type_="unique") batch_op.drop_column("uuid") diff --git a/superset/migrations/versions/b8d3a24d9131_new_dataset_models.py b/superset/migrations/versions/b8d3a24d9131_new_dataset_models.py index 75f5293034ead..e69d1606e3e71 100644 --- a/superset/migrations/versions/b8d3a24d9131_new_dataset_models.py +++ b/superset/migrations/versions/b8d3a24d9131_new_dataset_models.py @@ -23,611 +23,23 @@ Create Date: 2021-11-11 16:41:53.266965 """ - -import json -from typing import Callable, List, Optional, Set -from uuid import uuid4 - -import sqlalchemy as sa -from alembic import op -from sqlalchemy import and_, inspect, or_ -from sqlalchemy.engine.url import make_url -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import backref, relationship, Session -from sqlalchemy.schema import UniqueConstraint -from sqlalchemy_utils import UUIDType - -from superset import app, db -from superset.connectors.sqla.models import ADDITIVE_METRIC_TYPES -from superset.extensions import encrypted_field_factory -from superset.migrations.shared.utils import extract_table_references -from superset.models.core import Database as OriginalDatabase -from superset.sql_parse import Table - # revision identifiers, used by Alembic. revision = "b8d3a24d9131" down_revision = "5afbb1a5849b" -Base = declarative_base() -custom_password_store = app.config["SQLALCHEMY_CUSTOM_PASSWORD_STORE"] -DB_CONNECTION_MUTATOR = app.config["DB_CONNECTION_MUTATOR"] - - -class Database(Base): - - __tablename__ = "dbs" - __table_args__ = (UniqueConstraint("database_name"),) - - id = sa.Column(sa.Integer, primary_key=True) - database_name = sa.Column(sa.String(250), unique=True, nullable=False) - sqlalchemy_uri = sa.Column(sa.String(1024), nullable=False) - password = sa.Column(encrypted_field_factory.create(sa.String(1024))) - impersonate_user = sa.Column(sa.Boolean, default=False) - encrypted_extra = sa.Column(encrypted_field_factory.create(sa.Text), nullable=True) - extra = sa.Column( - sa.Text, - default=json.dumps( - dict( - metadata_params={}, - engine_params={}, - metadata_cache_timeout={}, - schemas_allowed_for_file_upload=[], - ) - ), - ) - server_cert = sa.Column(encrypted_field_factory.create(sa.Text), nullable=True) - - -class TableColumn(Base): - - __tablename__ = "table_columns" - __table_args__ = (UniqueConstraint("table_id", "column_name"),) - - id = sa.Column(sa.Integer, primary_key=True) - table_id = sa.Column(sa.Integer, sa.ForeignKey("tables.id")) - is_active = sa.Column(sa.Boolean, default=True) - extra = sa.Column(sa.Text) - column_name = sa.Column(sa.String(255), nullable=False) - type = sa.Column(sa.String(32)) - expression = sa.Column(sa.Text) - description = sa.Column(sa.Text) - is_dttm = sa.Column(sa.Boolean, default=False) - filterable = sa.Column(sa.Boolean, default=True) - groupby = sa.Column(sa.Boolean, default=True) - verbose_name = sa.Column(sa.String(1024)) - python_date_format = sa.Column(sa.String(255)) - - -class SqlMetric(Base): - - __tablename__ = "sql_metrics" - __table_args__ = (UniqueConstraint("table_id", "metric_name"),) - - id = sa.Column(sa.Integer, primary_key=True) - table_id = sa.Column(sa.Integer, sa.ForeignKey("tables.id")) - extra = sa.Column(sa.Text) - metric_type = sa.Column(sa.String(32)) - metric_name = sa.Column(sa.String(255), nullable=False) - expression = sa.Column(sa.Text, nullable=False) - warning_text = sa.Column(sa.Text) - description = sa.Column(sa.Text) - d3format = sa.Column(sa.String(128)) - verbose_name = sa.Column(sa.String(1024)) - - -class SqlaTable(Base): - - __tablename__ = "tables" - __table_args__ = (UniqueConstraint("database_id", "schema", "table_name"),) - - def fetch_columns_and_metrics(self, session: Session) -> None: - self.columns = session.query(TableColumn).filter( - TableColumn.table_id == self.id - ) - self.metrics = session.query(SqlMetric).filter(TableColumn.table_id == self.id) - - id = sa.Column(sa.Integer, primary_key=True) - columns: List[TableColumn] = [] - column_class = TableColumn - metrics: List[SqlMetric] = [] - metric_class = SqlMetric - - database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False) - database: Database = relationship( - "Database", - backref=backref("tables", cascade="all, delete-orphan"), - foreign_keys=[database_id], - ) - schema = sa.Column(sa.String(255)) - table_name = sa.Column(sa.String(250), nullable=False) - sql = sa.Column(sa.Text) - is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) - external_url = sa.Column(sa.Text, nullable=True) - - -table_column_association_table = sa.Table( - "sl_table_columns", - Base.metadata, - sa.Column("table_id", sa.ForeignKey("sl_tables.id")), - sa.Column("column_id", sa.ForeignKey("sl_columns.id")), -) - -dataset_column_association_table = sa.Table( - "sl_dataset_columns", - Base.metadata, - sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id")), - sa.Column("column_id", sa.ForeignKey("sl_columns.id")), -) - -dataset_table_association_table = sa.Table( - "sl_dataset_tables", - Base.metadata, - sa.Column("dataset_id", sa.ForeignKey("sl_datasets.id")), - sa.Column("table_id", sa.ForeignKey("sl_tables.id")), -) - - -class NewColumn(Base): - - __tablename__ = "sl_columns" - - id = sa.Column(sa.Integer, primary_key=True) - name = sa.Column(sa.Text) - type = sa.Column(sa.Text) - expression = sa.Column(sa.Text) - is_physical = sa.Column(sa.Boolean, default=True) - description = sa.Column(sa.Text) - warning_text = sa.Column(sa.Text) - is_temporal = sa.Column(sa.Boolean, default=False) - is_aggregation = sa.Column(sa.Boolean, default=False) - is_additive = sa.Column(sa.Boolean, default=False) - is_spatial = sa.Column(sa.Boolean, default=False) - is_partition = sa.Column(sa.Boolean, default=False) - is_increase_desired = sa.Column(sa.Boolean, default=True) - is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) - external_url = sa.Column(sa.Text, nullable=True) - extra_json = sa.Column(sa.Text, default="{}") - - -class NewTable(Base): - - __tablename__ = "sl_tables" - __table_args__ = (UniqueConstraint("database_id", "catalog", "schema", "name"),) - - id = sa.Column(sa.Integer, primary_key=True) - name = sa.Column(sa.Text) - schema = sa.Column(sa.Text) - catalog = sa.Column(sa.Text) - database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False) - database: Database = relationship( - "Database", - backref=backref("new_tables", cascade="all, delete-orphan"), - foreign_keys=[database_id], - ) - columns: List[NewColumn] = relationship( - "NewColumn", secondary=table_column_association_table, cascade="all, delete" - ) - is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) - external_url = sa.Column(sa.Text, nullable=True) - -class NewDataset(Base): - - __tablename__ = "sl_datasets" - - id = sa.Column(sa.Integer, primary_key=True) - sqlatable_id = sa.Column(sa.Integer, nullable=True, unique=True) - name = sa.Column(sa.Text) - expression = sa.Column(sa.Text) - tables: List[NewTable] = relationship( - "NewTable", secondary=dataset_table_association_table - ) - columns: List[NewColumn] = relationship( - "NewColumn", secondary=dataset_column_association_table, cascade="all, delete" - ) - is_physical = sa.Column(sa.Boolean, default=False) - is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) - external_url = sa.Column(sa.Text, nullable=True) - - -TEMPORAL_TYPES = {"DATETIME", "DATE", "TIME", "TIMEDELTA"} - - -def load_or_create_tables( - session: Session, - database_id: int, - default_schema: Optional[str], - tables: Set[Table], - conditional_quote: Callable[[str], str], -) -> List[NewTable]: - """ - Load or create new table model instances. - """ - if not tables: - return [] - - # set the default schema in tables that don't have it - if default_schema: - tables = list(tables) - for i, table in enumerate(tables): - if table.schema is None: - tables[i] = Table(table.table, default_schema, table.catalog) - - # load existing tables - predicate = or_( - *[ - and_( - NewTable.database_id == database_id, - NewTable.schema == table.schema, - NewTable.name == table.table, - ) - for table in tables - ] - ) - new_tables = session.query(NewTable).filter(predicate).all() - - # use original database model to get the engine - engine = ( - session.query(OriginalDatabase) - .filter_by(id=database_id) - .one() - .get_sqla_engine(default_schema) - ) - inspector = inspect(engine) - - # add missing tables - existing = {(table.schema, table.name) for table in new_tables} - for table in tables: - if (table.schema, table.table) not in existing: - column_metadata = inspector.get_columns(table.table, schema=table.schema) - columns = [ - NewColumn( - name=column["name"], - type=str(column["type"]), - expression=conditional_quote(column["name"]), - is_temporal=column["type"].python_type.__name__.upper() - in TEMPORAL_TYPES, - is_aggregation=False, - is_physical=True, - is_spatial=False, - is_partition=False, - is_increase_desired=True, - ) - for column in column_metadata - ] - new_tables.append( - NewTable( - name=table.table, - schema=table.schema, - catalog=None, - database_id=database_id, - columns=columns, - ) - ) - existing.add((table.schema, table.table)) - - return new_tables - - -def after_insert(target: SqlaTable) -> None: # pylint: disable=too-many-locals - """ - Copy old datasets to the new models. - """ - session = inspect(target).session - - # get DB-specific conditional quoter for expressions that point to columns or - # table names - database = ( - target.database - or session.query(Database).filter_by(id=target.database_id).first() - ) - if not database: - return - url = make_url(database.sqlalchemy_uri) - dialect_class = url.get_dialect() - conditional_quote = dialect_class().identifier_preparer.quote - - # create columns - columns = [] - for column in target.columns: - # ``is_active`` might be ``None`` at this point, but it defaults to ``True``. - if column.is_active is False: - continue - - try: - extra_json = json.loads(column.extra or "{}") - except json.decoder.JSONDecodeError: - extra_json = {} - for attr in {"groupby", "filterable", "verbose_name", "python_date_format"}: - value = getattr(column, attr) - if value: - extra_json[attr] = value - - columns.append( - NewColumn( - name=column.column_name, - type=column.type or "Unknown", - expression=column.expression or conditional_quote(column.column_name), - description=column.description, - is_temporal=column.is_dttm, - is_aggregation=False, - is_physical=column.expression is None or column.expression == "", - is_spatial=False, - is_partition=False, - is_increase_desired=True, - extra_json=json.dumps(extra_json) if extra_json else None, - is_managed_externally=target.is_managed_externally, - external_url=target.external_url, - ), - ) - - # create metrics - for metric in target.metrics: - try: - extra_json = json.loads(metric.extra or "{}") - except json.decoder.JSONDecodeError: - extra_json = {} - for attr in {"verbose_name", "metric_type", "d3format"}: - value = getattr(metric, attr) - if value: - extra_json[attr] = value - - is_additive = ( - metric.metric_type and metric.metric_type.lower() in ADDITIVE_METRIC_TYPES - ) - - columns.append( - NewColumn( - name=metric.metric_name, - type="Unknown", # figuring this out would require a type inferrer - expression=metric.expression, - warning_text=metric.warning_text, - description=metric.description, - is_aggregation=True, - is_additive=is_additive, - is_physical=False, - is_spatial=False, - is_partition=False, - is_increase_desired=True, - extra_json=json.dumps(extra_json) if extra_json else None, - is_managed_externally=target.is_managed_externally, - external_url=target.external_url, - ), - ) - - # physical dataset - if not target.sql: - physical_columns = [column for column in columns if column.is_physical] - - # create table - table = NewTable( - name=target.table_name, - schema=target.schema, - catalog=None, # currently not supported - database_id=target.database_id, - columns=physical_columns, - is_managed_externally=target.is_managed_externally, - external_url=target.external_url, - ) - tables = [table] - - # virtual dataset - else: - # mark all columns as virtual (not physical) - for column in columns: - column.is_physical = False - - # find referenced tables - referenced_tables = extract_table_references(target.sql, dialect_class.name) - tables = load_or_create_tables( - session, - target.database_id, - target.schema, - referenced_tables, - conditional_quote, - ) - - # create the new dataset - dataset = NewDataset( - sqlatable_id=target.id, - name=target.table_name, - expression=target.sql or conditional_quote(target.table_name), - tables=tables, - columns=columns, - is_physical=not target.sql, - is_managed_externally=target.is_managed_externally, - external_url=target.external_url, - ) - session.add(dataset) - - -def upgrade(): - # Create tables for the new models. - op.create_table( - "sl_columns", - # AuditMixinNullable - sa.Column("created_on", sa.DateTime(), nullable=True), - sa.Column("changed_on", sa.DateTime(), nullable=True), - sa.Column("created_by_fk", sa.Integer(), nullable=True), - sa.Column("changed_by_fk", sa.Integer(), nullable=True), - # ExtraJSONMixin - sa.Column("extra_json", sa.Text(), nullable=True), - # ImportExportMixin - sa.Column("uuid", UUIDType(binary=True), primary_key=False, default=uuid4), - # Column - sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column("name", sa.TEXT(), nullable=False), - sa.Column("type", sa.TEXT(), nullable=False), - sa.Column("expression", sa.TEXT(), nullable=False), - sa.Column( - "is_physical", - sa.BOOLEAN(), - nullable=False, - default=True, - ), - sa.Column("description", sa.TEXT(), nullable=True), - sa.Column("warning_text", sa.TEXT(), nullable=True), - sa.Column("unit", sa.TEXT(), nullable=True), - sa.Column("is_temporal", sa.BOOLEAN(), nullable=False), - sa.Column( - "is_spatial", - sa.BOOLEAN(), - nullable=False, - default=False, - ), - sa.Column( - "is_partition", - sa.BOOLEAN(), - nullable=False, - default=False, - ), - sa.Column( - "is_aggregation", - sa.BOOLEAN(), - nullable=False, - default=False, - ), - sa.Column( - "is_additive", - sa.BOOLEAN(), - nullable=False, - default=False, - ), - sa.Column( - "is_increase_desired", - sa.BOOLEAN(), - nullable=False, - default=True, - ), - sa.Column( - "is_managed_externally", - sa.Boolean(), - nullable=False, - server_default=sa.false(), - ), - sa.Column("external_url", sa.Text(), nullable=True), - sa.PrimaryKeyConstraint("id"), - ) - with op.batch_alter_table("sl_columns") as batch_op: - batch_op.create_unique_constraint("uq_sl_columns_uuid", ["uuid"]) - - op.create_table( - "sl_tables", - # AuditMixinNullable - sa.Column("created_on", sa.DateTime(), nullable=True), - sa.Column("changed_on", sa.DateTime(), nullable=True), - sa.Column("created_by_fk", sa.Integer(), nullable=True), - sa.Column("changed_by_fk", sa.Integer(), nullable=True), - # ExtraJSONMixin - sa.Column("extra_json", sa.Text(), nullable=True), - # ImportExportMixin - sa.Column("uuid", UUIDType(binary=True), primary_key=False, default=uuid4), - # Table - sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column("database_id", sa.INTEGER(), autoincrement=False, nullable=False), - sa.Column("catalog", sa.TEXT(), nullable=True), - sa.Column("schema", sa.TEXT(), nullable=True), - sa.Column("name", sa.TEXT(), nullable=False), - sa.Column( - "is_managed_externally", - sa.Boolean(), - nullable=False, - server_default=sa.false(), - ), - sa.Column("external_url", sa.Text(), nullable=True), - sa.ForeignKeyConstraint(["database_id"], ["dbs.id"], name="sl_tables_ibfk_1"), - sa.PrimaryKeyConstraint("id"), - ) - with op.batch_alter_table("sl_tables") as batch_op: - batch_op.create_unique_constraint("uq_sl_tables_uuid", ["uuid"]) - - op.create_table( - "sl_table_columns", - sa.Column("table_id", sa.INTEGER(), autoincrement=False, nullable=False), - sa.Column("column_id", sa.INTEGER(), autoincrement=False, nullable=False), - sa.ForeignKeyConstraint( - ["column_id"], ["sl_columns.id"], name="sl_table_columns_ibfk_2" - ), - sa.ForeignKeyConstraint( - ["table_id"], ["sl_tables.id"], name="sl_table_columns_ibfk_1" - ), - ) - - op.create_table( - "sl_datasets", - # AuditMixinNullable - sa.Column("created_on", sa.DateTime(), nullable=True), - sa.Column("changed_on", sa.DateTime(), nullable=True), - sa.Column("created_by_fk", sa.Integer(), nullable=True), - sa.Column("changed_by_fk", sa.Integer(), nullable=True), - # ExtraJSONMixin - sa.Column("extra_json", sa.Text(), nullable=True), - # ImportExportMixin - sa.Column("uuid", UUIDType(binary=True), primary_key=False, default=uuid4), - # Dataset - sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column("sqlatable_id", sa.INTEGER(), nullable=True), - sa.Column("name", sa.TEXT(), nullable=False), - sa.Column("expression", sa.TEXT(), nullable=False), - sa.Column( - "is_physical", - sa.BOOLEAN(), - nullable=False, - default=False, - ), - sa.Column( - "is_managed_externally", - sa.Boolean(), - nullable=False, - server_default=sa.false(), - ), - sa.Column("external_url", sa.Text(), nullable=True), - sa.PrimaryKeyConstraint("id"), - ) - with op.batch_alter_table("sl_datasets") as batch_op: - batch_op.create_unique_constraint("uq_sl_datasets_uuid", ["uuid"]) - batch_op.create_unique_constraint( - "uq_sl_datasets_sqlatable_id", ["sqlatable_id"] - ) - - op.create_table( - "sl_dataset_columns", - sa.Column("dataset_id", sa.INTEGER(), autoincrement=False, nullable=False), - sa.Column("column_id", sa.INTEGER(), autoincrement=False, nullable=False), - sa.ForeignKeyConstraint( - ["column_id"], ["sl_columns.id"], name="sl_dataset_columns_ibfk_2" - ), - sa.ForeignKeyConstraint( - ["dataset_id"], ["sl_datasets.id"], name="sl_dataset_columns_ibfk_1" - ), - ) - - op.create_table( - "sl_dataset_tables", - sa.Column("dataset_id", sa.INTEGER(), autoincrement=False, nullable=False), - sa.Column("table_id", sa.INTEGER(), autoincrement=False, nullable=False), - sa.ForeignKeyConstraint( - ["dataset_id"], ["sl_datasets.id"], name="sl_dataset_tables_ibfk_1" - ), - sa.ForeignKeyConstraint( - ["table_id"], ["sl_tables.id"], name="sl_dataset_tables_ibfk_2" - ), - ) +# ===================== Notice ======================== +# +# Migrations made in this revision has been moved to `new_dataset_models_take_2` +# to fix performance issues as well as a couple of shortcomings in the original +# design. +# +# ====================================================== - # migrate existing datasets to the new models - bind = op.get_bind() - session = db.Session(bind=bind) # pylint: disable=no-member - datasets = session.query(SqlaTable).all() - for dataset in datasets: - dataset.fetch_columns_and_metrics(session) - after_insert(target=dataset) +def upgrade() -> None: + pass def downgrade(): - op.drop_table("sl_dataset_columns") - op.drop_table("sl_dataset_tables") - op.drop_table("sl_datasets") - op.drop_table("sl_table_columns") - op.drop_table("sl_tables") - op.drop_table("sl_columns") + pass diff --git a/superset/migrations/versions/b92d69a6643c_rename_csv_to_file.py b/superset/migrations/versions/b92d69a6643c_rename_csv_to_file.py index 1f94445ff459a..b816b24320152 100644 --- a/superset/migrations/versions/b92d69a6643c_rename_csv_to_file.py +++ b/superset/migrations/versions/b92d69a6643c_rename_csv_to_file.py @@ -17,14 +17,14 @@ """rename_csv_to_file Revision ID: b92d69a6643c -Revises: 32646df09c64 +Revises: aea15018d53b Create Date: 2021-09-19 14:42:20.130368 """ # revision identifiers, used by Alembic. revision = "b92d69a6643c" -down_revision = "32646df09c64" +down_revision = "aea15018d53b" import sqlalchemy as sa from alembic import op diff --git a/superset/migrations/versions/c501b7c653a3_add_missing_uuid_column.py b/superset/migrations/versions/c501b7c653a3_add_missing_uuid_column.py index 4cfbc104c01db..786b41a1c72b8 100644 --- a/superset/migrations/versions/c501b7c653a3_add_missing_uuid_column.py +++ b/superset/migrations/versions/c501b7c653a3_add_missing_uuid_column.py @@ -38,7 +38,7 @@ from superset import db from superset.migrations.versions.b56500de1855_add_uuid_column_to_import_mixin import ( - add_uuids, + assign_uuids, models, update_dashboards, ) @@ -73,7 +73,7 @@ def upgrade(): default=uuid4, ), ) - add_uuids(model, table_name, session) + assign_uuids(model, session) # add uniqueness constraint with op.batch_alter_table(table_name) as batch_op: diff --git a/superset/migrations/versions/c53bae8f08dd_add_saved_query_foreign_key_to_tab_state.py b/superset/migrations/versions/c53bae8f08dd_add_saved_query_foreign_key_to_tab_state.py index a70b95af0ad42..2a2d66a1fd19c 100644 --- a/superset/migrations/versions/c53bae8f08dd_add_saved_query_foreign_key_to_tab_state.py +++ b/superset/migrations/versions/c53bae8f08dd_add_saved_query_foreign_key_to_tab_state.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. """add_saved_query_foreign_key_to_tab_state + Revision ID: c53bae8f08dd Revises: bb38f40aa3ff Create Date: 2021-12-15 15:05:21.845777 diff --git a/tests/unit_tests/migrations/__init__.py b/superset/migrations/versions/cecc6bf46990_rm_time_range_endpoints_2.py similarity index 73% rename from tests/unit_tests/migrations/__init__.py rename to superset/migrations/versions/cecc6bf46990_rm_time_range_endpoints_2.py index 13a83393a9124..bd2532e88a1c2 100644 --- a/tests/unit_tests/migrations/__init__.py +++ b/superset/migrations/versions/cecc6bf46990_rm_time_range_endpoints_2.py @@ -14,3 +14,22 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +"""rm_time_range_endpoints_2 + +Revision ID: cecc6bf46990 +Revises: 9d8a8d575284 +Create Date: 2022-04-14 17:21:53.996022 + +""" + +# revision identifiers, used by Alembic. +revision = "cecc6bf46990" +down_revision = "9d8a8d575284" + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/superset/migrations/versions/e866bd2d4976_smaller_grid.py b/superset/migrations/versions/e866bd2d4976_smaller_grid.py index b674fd603073b..286be8a5fc9c6 100644 --- a/superset/migrations/versions/e866bd2d4976_smaller_grid.py +++ b/superset/migrations/versions/e866bd2d4976_smaller_grid.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. """smaller_grid + Revision ID: e866bd2d4976 Revises: 21e88bc06c02 Create Date: 2018-02-13 08:07:40.766277 diff --git a/superset/migrations/versions/f1410ed7ec95_migrate_native_filters_to_new_schema.py b/superset/migrations/versions/f1410ed7ec95_migrate_native_filters_to_new_schema.py index 630a7b1062ac6..46b8e5f958670 100644 --- a/superset/migrations/versions/f1410ed7ec95_migrate_native_filters_to_new_schema.py +++ b/superset/migrations/versions/f1410ed7ec95_migrate_native_filters_to_new_schema.py @@ -71,7 +71,7 @@ def downgrade_filters(native_filters: Iterable[Dict[str, Any]]) -> int: filter_state = default_data_mask.get("filterState") if filter_state is not None: changed_filters += 1 - value = filter_state["value"] + value = filter_state.get("value") native_filter["defaultValue"] = value return changed_filters diff --git a/superset/migrations/versions/f9847149153d_add_certifications_columns_to_slice.py b/superset/migrations/versions/f9847149153d_add_certifications_columns_to_slice.py index a7e8a2239fdfa..bc8f37fd03aca 100644 --- a/superset/migrations/versions/f9847149153d_add_certifications_columns_to_slice.py +++ b/superset/migrations/versions/f9847149153d_add_certifications_columns_to_slice.py @@ -17,7 +17,7 @@ """add_certifications_columns_to_slice Revision ID: f9847149153d -Revises: 0ca9e5f1dacd +Revises: 32646df09c64 Create Date: 2021-11-03 14:07:09.905194 """ @@ -27,7 +27,7 @@ from alembic import op revision = "f9847149153d" -down_revision = "0ca9e5f1dacd" +down_revision = "32646df09c64" def upgrade(): diff --git a/superset/models/core.py b/superset/models/core.py index fcc7cf16d8ef2..c2052749ad8a0 100755 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -43,9 +43,9 @@ Table, Text, ) -from sqlalchemy.engine import Connection, Dialect, Engine, url +from sqlalchemy.engine import Connection, Dialect, Engine from sqlalchemy.engine.reflection import Inspector -from sqlalchemy.engine.url import make_url, URL +from sqlalchemy.engine.url import URL from sqlalchemy.exc import ArgumentError from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import relationship @@ -54,6 +54,7 @@ from sqlalchemy.sql import expression, Select from superset import app, db_engine_specs, is_feature_enabled +from superset.databases.utils import make_url_safe from superset.db_engine_specs.base import TimeGrain from superset.extensions import cache_manager, encrypted_field_factory, security_manager from superset.models.helpers import AuditMixinNullable, ImportExportMixin @@ -242,16 +243,16 @@ def unique_name(self) -> str: @property def url_object(self) -> URL: - return make_url(self.sqlalchemy_uri_decrypted) + return make_url_safe(self.sqlalchemy_uri_decrypted) @property def backend(self) -> str: - sqlalchemy_url = make_url(self.sqlalchemy_uri_decrypted) - return sqlalchemy_url.get_backend_name() # pylint: disable=no-member + sqlalchemy_url = make_url_safe(self.sqlalchemy_uri_decrypted) + return sqlalchemy_url.get_backend_name() @property def parameters(self) -> Dict[str, Any]: - uri = make_url(self.sqlalchemy_uri_decrypted) + uri = make_url_safe(self.sqlalchemy_uri_decrypted) encrypted_extra = self.get_encrypted_extra() try: # pylint: disable=useless-suppression @@ -303,7 +304,7 @@ def connect_args(self) -> Dict[str, Any]: def get_password_masked_url_from_uri( # pylint: disable=invalid-name cls, uri: str ) -> URL: - sqlalchemy_url = make_url(uri) + sqlalchemy_url = make_url_safe(uri) return cls.get_password_masked_url(sqlalchemy_url) @classmethod @@ -314,7 +315,7 @@ def get_password_masked_url(cls, masked_url: URL) -> URL: return url_copy def set_sqlalchemy_uri(self, uri: str) -> None: - conn = sqla.engine.url.make_url(uri.strip()) + conn = make_url_safe(uri.strip()) if conn.password != PASSWORD_MASK and not custom_password_store: # do not over-write the password with the password mask self.password = conn.password @@ -361,7 +362,7 @@ def get_sqla_engine( source: Optional[utils.QuerySource] = None, ) -> Engine: extra = self.get_extra() - sqlalchemy_url = make_url(self.sqlalchemy_uri_decrypted) + sqlalchemy_url = make_url_safe(self.sqlalchemy_uri_decrypted) self.db_engine_spec.adjust_database_uri(sqlalchemy_url, schema) effective_username = self.get_effective_user(sqlalchemy_url, user_name) # If using MySQL or Presto for example, will set url.username @@ -407,12 +408,14 @@ def get_sqla_engine( except Exception as ex: raise self.db_engine_spec.get_dbapi_mapped_exception(ex) + @property + def quote_identifier(self) -> Callable[[str], str]: + """Add quotes to potential identifiter expressions if needed""" + return self.get_dialect().identifier_preparer.quote + def get_reserved_words(self) -> Set[str]: return self.get_dialect().preparer.reserved_words - def get_quoter(self) -> Callable[[str, Any], str]: - return self.get_dialect().identifier_preparer.quote - def get_df( # pylint: disable=too-many-locals self, sql: str, @@ -723,7 +726,7 @@ def get_schema_access_for_file_upload( # pylint: disable=invalid-name @property def sqlalchemy_uri_decrypted(self) -> str: try: - conn = sqla.engine.url.make_url(self.sqlalchemy_uri) + conn = make_url_safe(self.sqlalchemy_uri) except (ArgumentError, ValueError): # if the URI is invalid, ignore and return a placeholder url # (so users see 500 less often) @@ -783,7 +786,7 @@ def has_view_by_name(self, view_name: str, schema: Optional[str] = None) -> bool @memoized def get_dialect(self) -> Dialect: - sqla_url = url.make_url(self.sqlalchemy_uri_decrypted) + sqla_url = make_url_safe(self.sqlalchemy_uri_decrypted) return sqla_url.get_dialect()() diff --git a/superset/models/helpers.py b/superset/models/helpers.py index 86ac2c1a98717..3b4e99159f0b8 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -420,6 +420,10 @@ def changed_on_(self) -> Markup: def changed_on_delta_humanized(self) -> str: return self.changed_on_humanized + @renders("created_on") + def created_on_delta_humanized(self) -> str: + return self.created_on_humanized + @renders("changed_on") def changed_on_utc(self) -> str: # Convert naive datetime to UTC @@ -429,6 +433,10 @@ def changed_on_utc(self) -> str: def changed_on_humanized(self) -> str: return humanize.naturaltime(datetime.now() - self.changed_on) + @property + def created_on_humanized(self) -> str: + return humanize.naturaltime(datetime.now() - self.created_on) + @renders("changed_on") def modified(self) -> Markup: return Markup(f'{self.changed_on_humanized}') @@ -469,7 +477,7 @@ class ExtraJSONMixin: @property def extra(self) -> Dict[str, Any]: try: - return json.loads(self.extra_json) + return json.loads(self.extra_json) if self.extra_json else {} except (TypeError, JSONDecodeError) as exc: logger.error( "Unable to load an extra json: %r. Leaving empty.", exc, exc_info=True @@ -514,18 +522,23 @@ def warning_markdown(self) -> Optional[str]: def clone_model( - target: Model, ignore: Optional[List[str]] = None, **kwargs: Any + target: Model, + ignore: Optional[List[str]] = None, + keep_relations: Optional[List[str]] = None, + **kwargs: Any, ) -> Model: """ - Clone a SQLAlchemy model. + Clone a SQLAlchemy model. By default will only clone naive column attributes. + To include relationship attributes, use `keep_relations`. """ ignore = ignore or [] table = target.__table__ + primary_keys = table.primary_key.columns.keys() data = { attr: getattr(target, attr) - for attr in table.columns.keys() - if attr not in table.primary_key.columns.keys() and attr not in ignore + for attr in list(table.columns.keys()) + (keep_relations or []) + if attr not in primary_keys and attr not in ignore } data.update(kwargs) diff --git a/superset/models/sql_lab.py b/superset/models/sql_lab.py index 6a3b4ad8bfd7c..04d5fc9a94359 100644 --- a/superset/models/sql_lab.py +++ b/superset/models/sql_lab.py @@ -118,7 +118,7 @@ def to_dict(self) -> Dict[str, Any]: "changedOn": self.changed_on, "changed_on": self.changed_on.isoformat(), "dbId": self.database_id, - "db": self.database.database_name, + "db": self.database.database_name if self.database else None, "endDttm": self.end_time, "errorMessage": self.error_message, "executedSql": self.executed_sql, @@ -265,7 +265,7 @@ class TabState(Model, AuditMixinNullable, ExtraJSONMixin): active = Column(Boolean, default=False) # selected DB and schema - database_id = Column(Integer, ForeignKey("dbs.id")) + database_id = Column(Integer, ForeignKey("dbs.id", ondelete="CASCADE")) database = relationship("Database", foreign_keys=[database_id]) schema = Column(String(256)) @@ -282,7 +282,9 @@ class TabState(Model, AuditMixinNullable, ExtraJSONMixin): query_limit = Column(Integer) # latest query that was run - latest_query_id = Column(Integer, ForeignKey("query.client_id")) + latest_query_id = Column( + Integer, ForeignKey("query.client_id", ondelete="SET NULL") + ) latest_query = relationship("Query") # other properties @@ -322,7 +324,9 @@ class TableSchema(Model, AuditMixinNullable, ExtraJSONMixin): id = Column(Integer, primary_key=True, autoincrement=True) tab_state_id = Column(Integer, ForeignKey("tab_state.id", ondelete="CASCADE")) - database_id = Column(Integer, ForeignKey("dbs.id"), nullable=False) + database_id = Column( + Integer, ForeignKey("dbs.id", ondelete="CASCADE"), nullable=False + ) database = relationship("Database", foreign_keys=[database_id]) schema = Column(String(256)) table = Column(String(256)) diff --git a/superset/reports/api.py b/superset/reports/api.py index e0d2598249d66..2871125c9a322 100644 --- a/superset/reports/api.py +++ b/superset/reports/api.py @@ -189,6 +189,7 @@ def ensure_alert_reports_enabled(self) -> Optional[Response]: "name", "active", "created_by", + "owners", "type", "last_state", "creation_method", @@ -212,6 +213,7 @@ def ensure_alert_reports_enabled(self) -> Optional[Response]: "chart": "slice_name", "database": "database_name", "created_by": RelatedFieldFilter("first_name", FilterRelatedOwners), + "owners": RelatedFieldFilter("first_name", FilterRelatedOwners), } apispec_parameter_schemas = { diff --git a/superset/result_set.py b/superset/result_set.py index 19035b6d23788..82b0a313935e3 100644 --- a/superset/result_set.py +++ b/superset/result_set.py @@ -26,7 +26,7 @@ import pyarrow as pa from superset.db_engine_specs import BaseEngineSpec -from superset.superset_typing import DbapiDescription, DbapiResult +from superset.superset_typing import DbapiDescription, DbapiResult, ResultSetColumnType from superset.utils import core as utils logger = logging.getLogger(__name__) @@ -210,17 +210,17 @@ def size(self) -> int: return self.table.num_rows @property - def columns(self) -> List[Dict[str, Any]]: + def columns(self) -> List[ResultSetColumnType]: if not self.table.column_names: return [] columns = [] for col in self.table.schema: db_type_str = self.data_type(col.name, col.type) - column = { + column: ResultSetColumnType = { "name": col.name, "type": db_type_str, - "is_date": self.is_temporal(db_type_str), + "is_dttm": self.is_temporal(db_type_str), } columns.append(column) diff --git a/superset/security/api.py b/superset/security/api.py index b919e29f78ddd..6411ccf7be56b 100644 --- a/superset/security/api.py +++ b/superset/security/api.py @@ -25,6 +25,9 @@ from marshmallow import EXCLUDE, fields, post_load, Schema, ValidationError from marshmallow_enum import EnumField +from superset.embedded_dashboard.commands.exceptions import ( + EmbeddedDashboardNotFoundError, +) from superset.extensions import event_logger from superset.security.guest_token import GuestTokenResourceType @@ -142,13 +145,16 @@ def guest_token(self) -> Response: """ try: body = guest_token_create_schema.load(request.json) + self.appbuilder.sm.validate_guest_token_resources(body["resources"]) + # todo validate stuff: - # make sure the resource ids are valid # make sure username doesn't reference an existing user # check rls rules for validity? token = self.appbuilder.sm.create_guest_access_token( body["user"], body["resources"], body["rls"] ) return self.response(200, token=token) + except EmbeddedDashboardNotFoundError as error: + return self.response_400(message=error.message) except ValidationError as error: return self.response_400(message=error.messages) diff --git a/superset/security/manager.py b/superset/security/manager.py index f57f1166ce394..48d43d01d0f76 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -1313,6 +1313,24 @@ def _get_guest_token_jwt_audience() -> str: audience = audience() return audience + @staticmethod + def validate_guest_token_resources(resources: GuestTokenResources) -> None: + # pylint: disable=import-outside-toplevel + from superset.embedded.dao import EmbeddedDAO + from superset.embedded_dashboard.commands.exceptions import ( + EmbeddedDashboardNotFoundError, + ) + from superset.models.dashboard import Dashboard + + for resource in resources: + if resource["type"] == GuestTokenResourceType.DASHBOARD.value: + # TODO (embedded): remove this check once uuids are rolled out + dashboard = Dashboard.get(str(resource["id"])) + if not dashboard: + embedded = EmbeddedDAO.find_by_id(str(resource["id"])) + if not embedded: + raise EmbeddedDashboardNotFoundError() + def create_guest_access_token( self, user: GuestTokenUser, diff --git a/superset/sql_lab.py b/superset/sql_lab.py index 613db963e31c1..567ff0d13d592 100644 --- a/superset/sql_lab.py +++ b/superset/sql_lab.py @@ -186,7 +186,7 @@ def execute_sql_statement( # pylint: disable=too-many-arguments,too-many-locals apply_ctas: bool = False, ) -> SupersetResultSet: """Executes a single SQL statement""" - database = query.database + database: Database = query.database db_engine_spec = database.db_engine_spec parsed_query = ParsedQuery(sql_statement) sql = parsed_query.stripped() @@ -528,6 +528,8 @@ def execute_sql_statements( # pylint: disable=too-many-arguments, too-many-loca if store_results and results_backend: key = str(uuid.uuid4()) + payload["query"]["resultsKey"] = key + logger.info( "Query %s: Storing results in results backend, key: %s", str(query_id), key ) diff --git a/superset/sql_parse.py b/superset/sql_parse.py index 6bfb63c425c48..d377986f56573 100644 --- a/superset/sql_parse.py +++ b/superset/sql_parse.py @@ -18,7 +18,7 @@ import re from dataclasses import dataclass from enum import Enum -from typing import cast, List, Optional, Set, Tuple +from typing import Any, cast, Iterator, List, Optional, Set, Tuple from urllib import parse import sqlparse @@ -47,10 +47,16 @@ from superset.exceptions import QueryClauseValidationException +try: + from sqloxide import parse_sql as sqloxide_parse +except: # pylint: disable=bare-except + sqloxide_parse = None + RESULT_OPERATIONS = {"UNION", "INTERSECT", "EXCEPT", "SELECT"} ON_KEYWORD = "ON" PRECEDES_TABLE_NAME = {"FROM", "JOIN", "DESCRIBE", "WITH", "LEFT JOIN", "RIGHT JOIN"} CTE_PREFIX = "CTE__" + logger = logging.getLogger(__name__) @@ -176,6 +182,9 @@ def __str__(self) -> str: if part ) + def __eq__(self, __o: object) -> bool: + return str(self) == str(__o) + class ParsedQuery: def __init__(self, sql_statement: str, strip_comments: bool = False): @@ -574,7 +583,6 @@ def get_rls_for_table( return None template_processor = dataset.get_template_processor() - # pylint: disable=protected-access predicate = " AND ".join( str(filter_) for filter_ in dataset.get_sqla_row_level_filters(template_processor) @@ -699,3 +707,75 @@ def insert_rls( ) return token_list + + +# mapping between sqloxide and SQLAlchemy dialects +SQLOXITE_DIALECTS = { + "ansi": {"trino", "trinonative", "presto"}, + "hive": {"hive", "databricks"}, + "ms": {"mssql"}, + "mysql": {"mysql"}, + "postgres": { + "cockroachdb", + "hana", + "netezza", + "postgres", + "postgresql", + "redshift", + "vertica", + }, + "snowflake": {"snowflake"}, + "sqlite": {"sqlite", "gsheets", "shillelagh"}, + "clickhouse": {"clickhouse"}, +} + +RE_JINJA_VAR = re.compile(r"\{\{[^\{\}]+\}\}") +RE_JINJA_BLOCK = re.compile(r"\{[%#][^\{\}%#]+[%#]\}") + + +def extract_table_references( + sql_text: str, sqla_dialect: str, show_warning: bool = True +) -> Set["Table"]: + """ + Return all the dependencies from a SQL sql_text. + """ + dialect = "generic" + tree = None + + if sqloxide_parse: + for dialect, sqla_dialects in SQLOXITE_DIALECTS.items(): + if sqla_dialect in sqla_dialects: + break + sql_text = RE_JINJA_BLOCK.sub(" ", sql_text) + sql_text = RE_JINJA_VAR.sub("abc", sql_text) + try: + tree = sqloxide_parse(sql_text, dialect=dialect) + except Exception as ex: # pylint: disable=broad-except + if show_warning: + logger.warning( + "\nUnable to parse query with sqloxide:\n%s\n%s", sql_text, ex + ) + + # fallback to sqlparse + if not tree: + parsed = ParsedQuery(sql_text) + return parsed.tables + + def find_nodes_by_key(element: Any, target: str) -> Iterator[Any]: + """ + Find all nodes in a SQL tree matching a given key. + """ + if isinstance(element, list): + for child in element: + yield from find_nodes_by_key(child, target) + elif isinstance(element, dict): + for key, value in element.items(): + if key == target: + yield value + else: + yield from find_nodes_by_key(value, target) + + return { + Table(*[part["value"] for part in table["name"][::-1]]) + for table in find_nodes_by_key(tree, "Table") + } diff --git a/superset/superset_typing.py b/superset/superset_typing.py index 253d2b63551a8..1af04494d0c95 100644 --- a/superset/superset_typing.py +++ b/superset/superset_typing.py @@ -57,6 +57,16 @@ class AdhocColumn(TypedDict, total=False): sqlExpression: Optional[str] +class ResultSetColumnType(TypedDict): + """ + Superset virtual dataset column interface + """ + + name: str + type: Optional[str] + is_dttm: bool + + CacheConfig = Dict[str, Any] DbapiDescriptionRow = Tuple[ str, str, Optional[str], Optional[str], Optional[int], Optional[int], bool diff --git a/superset/tables/models.py b/superset/tables/models.py index e2489445c686b..9a0c07fdcf5a4 100644 --- a/superset/tables/models.py +++ b/superset/tables/models.py @@ -24,26 +24,41 @@ These models are not fully implemented, and shouldn't be used yet. """ -from typing import List +from typing import Any, Dict, Iterable, List, Optional, TYPE_CHECKING import sqlalchemy as sa from flask_appbuilder import Model -from sqlalchemy.orm import backref, relationship +from sqlalchemy import inspect +from sqlalchemy.orm import backref, relationship, Session from sqlalchemy.schema import UniqueConstraint +from sqlalchemy.sql import and_, or_ from superset.columns.models import Column +from superset.connectors.sqla.utils import get_physical_table_metadata from superset.models.core import Database from superset.models.helpers import ( AuditMixinNullable, ExtraJSONMixin, ImportExportMixin, ) +from superset.sql_parse import Table as TableName -association_table = sa.Table( +if TYPE_CHECKING: + from superset.datasets.models import Dataset + +table_column_association_table = sa.Table( "sl_table_columns", Model.metadata, # pylint: disable=no-member - sa.Column("table_id", sa.ForeignKey("sl_tables.id")), - sa.Column("column_id", sa.ForeignKey("sl_columns.id")), + sa.Column( + "table_id", + sa.ForeignKey("sl_tables.id", ondelete="cascade"), + primary_key=True, + ), + sa.Column( + "column_id", + sa.ForeignKey("sl_columns.id", ondelete="cascade"), + primary_key=True, + ), ) @@ -61,7 +76,6 @@ class Table(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin): __table_args__ = (UniqueConstraint("database_id", "catalog", "schema", "name"),) id = sa.Column(sa.Integer, primary_key=True) - database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False) database: Database = relationship( "Database", @@ -70,6 +84,19 @@ class Table(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin): backref=backref("new_tables", cascade="all, delete-orphan"), foreign_keys=[database_id], ) + # The relationship between datasets and columns is 1:n, but we use a + # many-to-many association table to avoid adding two mutually exclusive + # columns(dataset_id and table_id) to Column + columns: List[Column] = relationship( + "Column", + secondary=table_column_association_table, + cascade="all, delete-orphan", + single_parent=True, + # backref is needed for session to skip detaching `dataset` if only `column` + # is loaded. + backref="tables", + ) + datasets: List["Dataset"] # will be populated by Dataset.tables backref # We use ``sa.Text`` for these attributes because (1) in modern databases the # performance is the same as ``VARCHAR``[1] and (2) because some table names can be @@ -80,13 +107,96 @@ class Table(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin): schema = sa.Column(sa.Text) name = sa.Column(sa.Text) - # The relationship between tables and columns is 1:n, but we use a many-to-many - # association to differentiate between the relationship between datasets and - # columns. - columns: List[Column] = relationship( - "Column", secondary=association_table, cascade="all, delete" - ) - # Column is managed externally and should be read-only inside Superset is_managed_externally = sa.Column(sa.Boolean, nullable=False, default=False) external_url = sa.Column(sa.Text, nullable=True) + + @property + def fullname(self) -> str: + return str(TableName(table=self.name, schema=self.schema, catalog=self.catalog)) + + def __repr__(self) -> str: + return f"" + + def sync_columns(self) -> None: + """Sync table columns with the database. Keep metadata for existing columns""" + try: + column_metadata = get_physical_table_metadata( + self.database, self.name, self.schema + ) + except Exception: # pylint: disable=broad-except + column_metadata = [] + + existing_columns = {column.name: column for column in self.columns} + quote_identifier = self.database.quote_identifier + + def update_or_create_column(column_meta: Dict[str, Any]) -> Column: + column_name: str = column_meta["name"] + if column_name in existing_columns: + column = existing_columns[column_name] + else: + column = Column(name=column_name) + column.type = column_meta["type"] + column.is_temporal = column_meta["is_dttm"] + column.expression = quote_identifier(column_name) + column.is_aggregation = False + column.is_physical = True + column.is_spatial = False + column.is_partition = False # TODO: update with accurate is_partition + return column + + self.columns = [update_or_create_column(col) for col in column_metadata] + + @staticmethod + def bulk_load_or_create( + database: Database, + table_names: Iterable[TableName], + default_schema: Optional[str] = None, + sync_columns: Optional[bool] = False, + default_props: Optional[Dict[str, Any]] = None, + ) -> List["Table"]: + """ + Load or create multiple Table instances. + """ + if not table_names: + return [] + + if not database.id: + raise Exception("Database must be already saved to metastore") + + default_props = default_props or {} + session: Session = inspect(database).session + # load existing tables + predicate = or_( + *[ + and_( + Table.database_id == database.id, + Table.schema == (table.schema or default_schema), + Table.name == table.table, + ) + for table in table_names + ] + ) + all_tables = session.query(Table).filter(predicate).order_by(Table.id).all() + + # add missing tables and pull its columns + existing = {(table.schema, table.name) for table in all_tables} + for table in table_names: + schema = table.schema or default_schema + name = table.table + if (schema, name) not in existing: + new_table = Table( + database=database, + database_id=database.id, + name=name, + schema=schema, + catalog=None, + **default_props, + ) + if sync_columns: + new_table.sync_columns() + all_tables.append(new_table) + existing.add((schema, name)) + session.add(new_table) + + return all_tables diff --git a/superset/tasks/async_queries.py b/superset/tasks/async_queries.py index 6a42d961e9d9d..74adcd080c0c3 100644 --- a/superset/tasks/async_queries.py +++ b/superset/tasks/async_queries.py @@ -47,17 +47,16 @@ def ensure_user_is_set(user_id: Optional[int]) -> None: user_is_not_set = not (hasattr(g, "user") and g.user is not None) if user_is_not_set and user_id is not None: - g.user = security_manager.get_user_by_id( # pylint: disable=assigning-non-slot - user_id - ) + # pylint: disable=assigning-non-slot + g.user = security_manager.get_user_by_id(user_id) elif user_is_not_set: - g.user = ( # pylint: disable=assigning-non-slot - security_manager.get_anonymous_user() - ) + # pylint: disable=assigning-non-slot + g.user = security_manager.get_anonymous_user() def set_form_data(form_data: Dict[str, Any]) -> None: - g.form_data = form_data # pylint: disable=assigning-non-slot + # pylint: disable=assigning-non-slot + g.form_data = form_data def _create_query_context_from_form(form_data: Dict[str, Any]) -> QueryContext: diff --git a/superset/translations/de/LC_MESSAGES/messages.json b/superset/translations/de/LC_MESSAGES/messages.json index 1a55e18f6b5f6..81b02949dd774 100644 --- a/superset/translations/de/LC_MESSAGES/messages.json +++ b/superset/translations/de/LC_MESSAGES/messages.json @@ -199,7 +199,7 @@ "Actions": ["Aktion"], "Active": ["Aktiv"], "Actual time range": ["Tatsächlicher Zeitbereich"], - "Adaptative formating": ["Adaptative Formatierung"], + "Adaptive formatting": ["Adaptative Formatierung"], "Add": ["Hinzufügen"], "Add Alert": ["Alarm hinzufügen"], "Add Annotation": ["Anmerkungen hinzufügen"], diff --git a/superset/translations/de/LC_MESSAGES/messages.po b/superset/translations/de/LC_MESSAGES/messages.po index 7b8df3715cdc0..6feca31c40ca3 100644 --- a/superset/translations/de/LC_MESSAGES/messages.po +++ b/superset/translations/de/LC_MESSAGES/messages.po @@ -703,7 +703,7 @@ msgstr "Tatsächlicher Zeitbereich" #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:28 #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:49 -msgid "Adaptative formating" +msgid "Adaptive formatting" msgstr "Adaptative Formatierung" #: superset-frontend/src/components/ReportModal/index.tsx:267 diff --git a/superset/translations/en/LC_MESSAGES/messages.po b/superset/translations/en/LC_MESSAGES/messages.po index 790fd131ffcd8..332f0e50c4402 100644 --- a/superset/translations/en/LC_MESSAGES/messages.po +++ b/superset/translations/en/LC_MESSAGES/messages.po @@ -600,7 +600,7 @@ msgstr "" #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:28 #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:49 -msgid "Adaptative formating" +msgid "Adaptive formatting" msgstr "" #: superset-frontend/src/components/ReportModal/index.tsx:267 diff --git a/superset/translations/es/LC_MESSAGES/messages.po b/superset/translations/es/LC_MESSAGES/messages.po index 300892928dfc1..2270f0db52d3c 100644 --- a/superset/translations/es/LC_MESSAGES/messages.po +++ b/superset/translations/es/LC_MESSAGES/messages.po @@ -631,7 +631,7 @@ msgstr "Rango de tiempo actual" #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:28 #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:49 #, fuzzy -msgid "Adaptative formating" +msgid "Adaptive formatting" msgstr "Formato Fecha/Hora" #: superset-frontend/src/components/ReportModal/index.tsx:267 diff --git a/superset/translations/fr/LC_MESSAGES/messages.po b/superset/translations/fr/LC_MESSAGES/messages.po index 78caa3700ad9c..a54668777c51f 100644 --- a/superset/translations/fr/LC_MESSAGES/messages.po +++ b/superset/translations/fr/LC_MESSAGES/messages.po @@ -675,7 +675,7 @@ msgstr "Intervalle de temps courant" #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:28 #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:49 #, fuzzy -msgid "Adaptative formating" +msgid "Adaptive formatting" msgstr "Format Datetime" #: superset-frontend/src/components/ReportModal/index.tsx:267 diff --git a/superset/translations/it/LC_MESSAGES/messages.po b/superset/translations/it/LC_MESSAGES/messages.po index 7c6ae5811275b..41353893bc09c 100644 --- a/superset/translations/it/LC_MESSAGES/messages.po +++ b/superset/translations/it/LC_MESSAGES/messages.po @@ -613,7 +613,7 @@ msgstr "" #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:28 #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:49 #, fuzzy -msgid "Adaptative formating" +msgid "Adaptive formatting" msgstr "Formato Datetime" #: superset-frontend/src/components/ReportModal/index.tsx:267 diff --git a/superset/translations/ja/LC_MESSAGES/messages.po b/superset/translations/ja/LC_MESSAGES/messages.po index aecf488f43a3a..f89d9f0b2b818 100644 --- a/superset/translations/ja/LC_MESSAGES/messages.po +++ b/superset/translations/ja/LC_MESSAGES/messages.po @@ -608,7 +608,7 @@ msgstr "実際の期間" #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:28 #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:49 #, fuzzy -msgid "Adaptative formating" +msgid "Adaptive formatting" msgstr "日時フォーマット" #: superset-frontend/src/components/ReportModal/index.tsx:267 diff --git a/superset/translations/ko/LC_MESSAGES/messages.po b/superset/translations/ko/LC_MESSAGES/messages.po index 0c929a621fc0d..db408ecdcf3a6 100644 --- a/superset/translations/ko/LC_MESSAGES/messages.po +++ b/superset/translations/ko/LC_MESSAGES/messages.po @@ -609,7 +609,7 @@ msgstr "" #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:28 #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:49 -msgid "Adaptative formating" +msgid "Adaptive formatting" msgstr "" #: superset-frontend/src/components/ReportModal/index.tsx:267 diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot index 02993d2e4f5fe..53f2e0506df02 100644 --- a/superset/translations/messages.pot +++ b/superset/translations/messages.pot @@ -606,7 +606,7 @@ msgstr "" #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:28 #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:49 -msgid "Adaptative formating" +msgid "Adaptive formatting" msgstr "" #: superset-frontend/src/components/ReportModal/index.tsx:267 diff --git a/superset/translations/nl/LC_MESSAGES/messages.json b/superset/translations/nl/LC_MESSAGES/messages.json index bfa0cd7100bab..5f0f985132580 100644 --- a/superset/translations/nl/LC_MESSAGES/messages.json +++ b/superset/translations/nl/LC_MESSAGES/messages.json @@ -1777,7 +1777,7 @@ "" ], "D3 format syntax: https://github.com/d3/d3-format": [""], - "Adaptative formating": [""], + "Adaptive formatting": [""], "Duration in ms (66000 => 1m 6s)": [""], "Duration in ms (1.40008 => 1ms 400µs 80ns)": [""], "D3 time format syntax: https://github.com/d3/d3-time-format": [""], diff --git a/superset/translations/nl/LC_MESSAGES/messages.po b/superset/translations/nl/LC_MESSAGES/messages.po index 0aab807a6e247..0f454a727a03f 100644 --- a/superset/translations/nl/LC_MESSAGES/messages.po +++ b/superset/translations/nl/LC_MESSAGES/messages.po @@ -5036,7 +5036,7 @@ msgstr "" #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:28 #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:49 -msgid "Adaptative formating" +msgid "Adaptive formatting" msgstr "" #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:40 diff --git a/superset/translations/pt_BR/LC_MESSAGES/messages.po b/superset/translations/pt_BR/LC_MESSAGES/messages.po index bbc54e2536cc7..0092e89083ee2 100644 --- a/superset/translations/pt_BR/LC_MESSAGES/messages.po +++ b/superset/translations/pt_BR/LC_MESSAGES/messages.po @@ -671,7 +671,7 @@ msgstr "Intervalo de tempo real" #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:28 #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:49 #, fuzzy -msgid "Adaptative formating" +msgid "Adaptive formatting" msgstr "Formato de data e hora" #: superset-frontend/src/components/ReportModal/index.tsx:267 diff --git a/superset/translations/ru/LC_MESSAGES/messages.po b/superset/translations/ru/LC_MESSAGES/messages.po index ab1a594c91350..9b79a3ea5708a 100644 --- a/superset/translations/ru/LC_MESSAGES/messages.po +++ b/superset/translations/ru/LC_MESSAGES/messages.po @@ -648,7 +648,7 @@ msgstr "" #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:28 #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:49 #, fuzzy -msgid "Adaptative formating" +msgid "Adaptive formatting" msgstr "Формат Datetime" #: superset-frontend/src/components/ReportModal/index.tsx:267 diff --git a/superset/translations/sk/LC_MESSAGES/messages.po b/superset/translations/sk/LC_MESSAGES/messages.po index 93bc39980a6d8..cd8d69c0ebd40 100644 --- a/superset/translations/sk/LC_MESSAGES/messages.po +++ b/superset/translations/sk/LC_MESSAGES/messages.po @@ -600,7 +600,7 @@ msgstr "" #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:28 #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:49 -msgid "Adaptative formating" +msgid "Adaptive formatting" msgstr "" #: superset-frontend/src/components/ReportModal/index.tsx:267 diff --git a/superset/translations/sl/LC_MESSAGES/messages.json b/superset/translations/sl/LC_MESSAGES/messages.json index 73577bebaedae..7c434181052ec 100644 --- a/superset/translations/sl/LC_MESSAGES/messages.json +++ b/superset/translations/sl/LC_MESSAGES/messages.json @@ -3816,7 +3816,7 @@ "D3 format syntax: https://github.com/d3/d3-format": [ "Sintaksa D3 formata: https://github.com/d3/d3-format" ], - "Adaptative formating": ["Adaptivno oblikovanje"], + "Adaptive formatting": ["Adaptivno oblikovanje"], "Duration in ms (66000 => 1m 6s)": ["Trajanje v ms (66000 => 1m 6s)"], "Duration in ms (1.40008 => 1ms 400µs 80ns)": [ "Trajanje v ms (1.40008 => 1ms 400µs 80ns)" diff --git a/superset/translations/sl/LC_MESSAGES/messages.po b/superset/translations/sl/LC_MESSAGES/messages.po index 40fe12790ff9b..5171d95052dd6 100644 --- a/superset/translations/sl/LC_MESSAGES/messages.po +++ b/superset/translations/sl/LC_MESSAGES/messages.po @@ -678,7 +678,7 @@ msgstr "Dejansko časovno obdobje" #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:28 #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:49 -msgid "Adaptative formating" +msgid "Adaptive formatting" msgstr "Adaptivno oblikovanje" #: superset-frontend/src/components/ReportModal/index.tsx:267 diff --git a/superset/translations/zh/LC_MESSAGES/messages.json b/superset/translations/zh/LC_MESSAGES/messages.json index 035be3ff92762..3c09c4a3d5d71 100644 --- a/superset/translations/zh/LC_MESSAGES/messages.json +++ b/superset/translations/zh/LC_MESSAGES/messages.json @@ -2617,7 +2617,7 @@ "Whether to apply filter to dashboards when table cells are clicked": [ "单击表单元格时是否对看板应用过滤条件" ], - "Adaptative formating": ["自动匹配格式化"], + "Adaptive formatting": ["自动匹配格式化"], "Aggregate": ["聚合"], "Raw Records": ["原始记录"], "Query Mode": ["查询模式"], @@ -2824,8 +2824,8 @@ "Annotation Source": ["注释来源"], "No options": ["没有选项"], "Superset annotation": ["Superset注释"], - "Use a pre defined Superset Chart as a source for annotations and overlays. your chart must be one of these visualization types:": [ - "使用预定义的图表作为注释和覆盖的源。图表必须是以下可视化类型之一:" + "Use another existing chart as a source for annotations and overlays. Your chart must be one of these visualization types: [%s]": [ + "使用预定义的图表作为注释和覆盖的源。图表必须是以下可视化类型之一: [%s]" ], "Expects a formula with depending time parameter 'x'\n in milliseconds since epoch. mathjs is used to evaluate the formulas.\n Example: '2x+5'": [ "需要一个从Epoch(1970年1月1日00:00:00 UTC)时间点开始的时间参数“x”,并以此来计算的公式(以毫秒为单位)。我们使用“mathjs”来进行公式的计算。例如:'2x+5'" diff --git a/superset/translations/zh/LC_MESSAGES/messages.po b/superset/translations/zh/LC_MESSAGES/messages.po index e2bc4920cac4e..1d6e5e25b5c3b 100644 --- a/superset/translations/zh/LC_MESSAGES/messages.po +++ b/superset/translations/zh/LC_MESSAGES/messages.po @@ -637,7 +637,7 @@ msgstr "实际时间范围" #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:28 #: superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts:49 -msgid "Adaptative formating" +msgid "Adaptive formatting" msgstr "自动匹配格式化" #: superset-frontend/src/components/ReportModal/index.tsx:267 diff --git a/superset/utils/cache_manager.py b/superset/utils/cache_manager.py index c67644f308b1f..3f071b15435b6 100644 --- a/superset/utils/cache_manager.py +++ b/superset/utils/cache_manager.py @@ -44,7 +44,7 @@ def _init_cache( if cache_type is None and not app.debug: logger.warning( "Falling back to the built-in cache, that stores data in the " - "metadata database, for the followinng cache: `%s`. " + "metadata database, for the following cache: `%s`. " "It is recommended to use `RedisCache`, `MemcachedCache` or " "another dedicated caching backend for production deployments", cache_config_key, diff --git a/superset/utils/core.py b/superset/utils/core.py index fbfbbf52f5699..68caf7872c95a 100644 --- a/superset/utils/core.py +++ b/superset/utils/core.py @@ -128,7 +128,7 @@ NO_TIME_RANGE = "No filter" -TIME_COMPARISION = "__" +TIME_COMPARISON = "__" JS_MAX_INTEGER = 9007199254740991 # Largest int Java Script can handle 2^53-1 diff --git a/superset/utils/pandas_postprocessing/__init__.py b/superset/utils/pandas_postprocessing/__init__.py index 3d180bc372020..9755df984cc56 100644 --- a/superset/utils/pandas_postprocessing/__init__.py +++ b/superset/utils/pandas_postprocessing/__init__.py @@ -28,6 +28,7 @@ ) from superset.utils.pandas_postprocessing.pivot import pivot from superset.utils.pandas_postprocessing.prophet import prophet +from superset.utils.pandas_postprocessing.rename import rename from superset.utils.pandas_postprocessing.resample import resample from superset.utils.pandas_postprocessing.rolling import rolling from superset.utils.pandas_postprocessing.select import select @@ -46,6 +47,7 @@ "geodetic_parse", "pivot", "prophet", + "rename", "resample", "rolling", "select", diff --git a/superset/utils/pandas_postprocessing/compare.py b/superset/utils/pandas_postprocessing/compare.py index 18a66cec13456..f7c8365508750 100644 --- a/superset/utils/pandas_postprocessing/compare.py +++ b/superset/utils/pandas_postprocessing/compare.py @@ -22,7 +22,7 @@ from superset.constants import PandasPostprocessingCompare from superset.exceptions import InvalidPostProcessingError -from superset.utils.core import TIME_COMPARISION +from superset.utils.core import TIME_COMPARISON from superset.utils.pandas_postprocessing.utils import validate_column_args @@ -65,17 +65,16 @@ def compare( # pylint: disable=too-many-arguments c_df = df.loc[:, [c_col]] c_df.rename(columns={c_col: "__intermediate"}, inplace=True) if compare_type == PandasPostprocessingCompare.DIFF: - diff_df = c_df - s_df + diff_df = s_df - c_df elif compare_type == PandasPostprocessingCompare.PCT: - # https://en.wikipedia.org/wiki/Relative_change_and_difference#Percentage_change - diff_df = ((c_df - s_df) / s_df).astype(float).round(precision) + diff_df = ((s_df - c_df) / c_df).astype(float).round(precision) else: # compare_type == "ratio" - diff_df = (c_df / s_df).astype(float).round(precision) + diff_df = (s_df / c_df).astype(float).round(precision) diff_df.rename( columns={ - "__intermediate": TIME_COMPARISION.join([compare_type, s_col, c_col]) + "__intermediate": TIME_COMPARISON.join([compare_type, s_col, c_col]) }, inplace=True, ) diff --git a/superset/utils/pandas_postprocessing/flatten.py b/superset/utils/pandas_postprocessing/flatten.py index 49f250ec1c9b9..2874ac57970a4 100644 --- a/superset/utils/pandas_postprocessing/flatten.py +++ b/superset/utils/pandas_postprocessing/flatten.py @@ -14,7 +14,11 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. + +from typing import Sequence, Union + import pandas as pd +from numpy.distutils.misc_util import is_sequence from superset.utils.pandas_postprocessing.utils import ( _is_multi_index_on_columns, @@ -25,12 +29,15 @@ def flatten( df: pd.DataFrame, reset_index: bool = True, + drop_levels: Union[Sequence[int], Sequence[str]] = (), ) -> pd.DataFrame: """ Convert N-dimensional DataFrame to a flat DataFrame :param df: N-dimensional DataFrame. :param reset_index: Convert index to column when df.index isn't RangeIndex + :param drop_levels: index of level or names of level might be dropped + if df is N-dimensional :return: a flat DataFrame Examples @@ -73,11 +80,17 @@ def flatten( 2 2021-01-03 1 1 1 1 """ if _is_multi_index_on_columns(df): - # every cell should be converted to string - df.columns = [ - FLAT_COLUMN_SEPARATOR.join([str(cell) for cell in series]) - for series in df.columns.to_flat_index() - ] + df.columns = df.columns.droplevel(drop_levels) + _columns = [] + for series in df.columns.to_flat_index(): + _cells = [] + for cell in series if is_sequence(series) else [series]: + if pd.notnull(cell): + # every cell should be converted to string + _cells.append(str(cell)) + _columns.append(FLAT_COLUMN_SEPARATOR.join(_cells)) + + df.columns = _columns if reset_index and not isinstance(df.index, pd.RangeIndex): df = df.reset_index(level=0) diff --git a/superset/utils/pandas_postprocessing/rename.py b/superset/utils/pandas_postprocessing/rename.py new file mode 100644 index 0000000000000..0e35a651a8073 --- /dev/null +++ b/superset/utils/pandas_postprocessing/rename.py @@ -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 limitations +# under the License. +from typing import Dict, Optional, Union + +import pandas as pd +from flask_babel import gettext as _ +from pandas._typing import Level + +from superset.exceptions import InvalidPostProcessingError +from superset.utils.pandas_postprocessing.utils import validate_column_args + + +@validate_column_args("columns") +def rename( + df: pd.DataFrame, + columns: Dict[str, Union[str, None]], + inplace: bool = False, + level: Optional[Level] = None, +) -> pd.DataFrame: + """ + Alter column name of DataFrame + + :param df: DataFrame to rename. + :param columns: The offset string representing target conversion. + :param inplace: Whether to return a new DataFrame. + :param level: In case of a MultiIndex, only rename labels in the specified level. + :return: DataFrame after rename + :raises InvalidPostProcessingError: If the request is unexpected + """ + if not columns: + return df + + try: + _rename_level = df.columns.get_level_values(level=level) + except (IndexError, KeyError) as err: + raise InvalidPostProcessingError from err + + if all(new_name in _rename_level for new_name in columns.values()): + raise InvalidPostProcessingError(_("Label already exists")) + + if inplace: + df.rename(columns=columns, inplace=inplace, level=level) + return df + return df.rename(columns=columns, inplace=inplace, level=level) diff --git a/superset/views/alerts.py b/superset/views/alerts.py index b97587ec71855..ad0fefec64044 100644 --- a/superset/views/alerts.py +++ b/superset/views/alerts.py @@ -52,3 +52,8 @@ def log(self, pk: int) -> FlaskResponse: # pylint: disable=unused-argument class AlertView(BaseAlertReportView): route_base = "/alert" class_permission_name = "ReportSchedule" + + +class ReportView(BaseAlertReportView): + route_base = "/report" + class_permission_name = "ReportSchedule" diff --git a/superset/views/base.py b/superset/views/base.py index 863ca2f84ab67..22e4c5f8d163b 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -45,7 +45,7 @@ from flask_wtf.csrf import CSRFError from flask_wtf.form import FlaskForm from pkg_resources import resource_filename -from sqlalchemy import or_ +from sqlalchemy import exc, or_ from sqlalchemy.orm import Query from werkzeug.exceptions import HTTPException from wtforms import Form @@ -231,6 +231,9 @@ def wraps(self: "BaseSupersetView", *args: Any, **kwargs: Any) -> FlaskResponse: return json_error_response( utils.error_msg_from_exception(ex), status=cast(int, ex.code) ) + except (exc.IntegrityError, exc.DatabaseError, exc.DataError) as ex: + logger.exception(ex) + return json_error_response(utils.error_msg_from_exception(ex), status=422) except Exception as ex: # pylint: disable=broad-except logger.exception(ex) return json_error_response(utils.error_msg_from_exception(ex)) diff --git a/superset/views/base_api.py b/superset/views/base_api.py index 260e5731788bc..01b462bb321f6 100644 --- a/superset/views/base_api.py +++ b/superset/views/base_api.py @@ -39,6 +39,7 @@ from superset.stats_logger import BaseStatsLogger from superset.superset_typing import FlaskResponse from superset.utils.core import time_function +from superset.views.base import handle_api_exception logger = logging.getLogger(__name__) get_related_schema = { @@ -386,6 +387,7 @@ def send_stats_metrics( object_ref=False, log_to_statsd=False, ) + @handle_api_exception def info_headless(self, **kwargs: Any) -> Response: """ Add statsd metrics to builtin FAB _info endpoint @@ -399,6 +401,7 @@ def info_headless(self, **kwargs: Any) -> Response: object_ref=False, log_to_statsd=False, ) + @handle_api_exception def get_headless(self, pk: int, **kwargs: Any) -> Response: """ Add statsd metrics to builtin FAB GET endpoint @@ -412,6 +415,7 @@ def get_headless(self, pk: int, **kwargs: Any) -> Response: object_ref=False, log_to_statsd=False, ) + @handle_api_exception def get_list_headless(self, **kwargs: Any) -> Response: """ Add statsd metrics to builtin FAB GET list endpoint @@ -425,6 +429,7 @@ def get_list_headless(self, **kwargs: Any) -> Response: object_ref=False, log_to_statsd=False, ) + @handle_api_exception def post_headless(self) -> Response: """ Add statsd metrics to builtin FAB POST endpoint @@ -438,6 +443,7 @@ def post_headless(self) -> Response: object_ref=False, log_to_statsd=False, ) + @handle_api_exception def put_headless(self, pk: int) -> Response: """ Add statsd metrics to builtin FAB PUT endpoint @@ -451,6 +457,7 @@ def put_headless(self, pk: int) -> Response: object_ref=False, log_to_statsd=False, ) + @handle_api_exception def delete_headless(self, pk: int) -> Response: """ Add statsd metrics to builtin FAB DELETE endpoint @@ -464,6 +471,7 @@ def delete_headless(self, pk: int) -> Response: @safe @statsd_metrics @rison(get_related_schema) + @handle_api_exception def related(self, column_name: str, **kwargs: Any) -> FlaskResponse: """Get related fields data --- @@ -542,6 +550,7 @@ def related(self, column_name: str, **kwargs: Any) -> FlaskResponse: @safe @statsd_metrics @rison(get_related_schema) + @handle_api_exception def distinct(self, column_name: str, **kwargs: Any) -> FlaskResponse: """Get distinct values from field data --- diff --git a/superset/views/core.py b/superset/views/core.py index c806470e8f1de..68ac74b365852 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -39,8 +39,7 @@ from flask_appbuilder.security.sqla import models as ab_models from flask_babel import gettext as __, lazy_gettext as _ from sqlalchemy import and_, or_ -from sqlalchemy.engine.url import make_url -from sqlalchemy.exc import ArgumentError, DBAPIError, NoSuchModuleError, SQLAlchemyError +from sqlalchemy.exc import DBAPIError, NoSuchModuleError, SQLAlchemyError from sqlalchemy.orm.session import Session from sqlalchemy.sql import functions as func @@ -73,8 +72,10 @@ from superset.dashboards.dao import DashboardDAO from superset.dashboards.permalink.commands.get import GetDashboardPermalinkCommand from superset.dashboards.permalink.exceptions import DashboardPermalinkGetFailedError +from superset.databases.commands.exceptions import DatabaseInvalidError from superset.databases.dao import DatabaseDAO from superset.databases.filters import DatabaseFilter +from superset.databases.utils import make_url_safe from superset.datasets.commands.exceptions import DatasetNotFoundError from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.exceptions import ( @@ -1330,7 +1331,7 @@ def testconn(self) -> FlaskResponse: # pylint: disable=no-self-use uri = request.json.get("uri") try: if app.config["PREVENT_UNSAFE_DB_CONNECTIONS"]: - check_sqlalchemy_uri(make_url(uri)) + check_sqlalchemy_uri(make_url_safe(uri)) # if the database already exists in the database, only its safe # (password-masked) URI would be shown in the UI and would be passed in the # form data so if the database already exists and the form was submitted @@ -1371,7 +1372,7 @@ def testconn(self) -> FlaskResponse: # pylint: disable=no-self-use return json_error_response(ex.message) except (NoSuchModuleError, ModuleNotFoundError): logger.info("Invalid driver") - driver_name = make_url(uri).drivername + driver_name = make_url_safe(uri).drivername return json_error_response( _( "Could not load database driver: %(driver_name)s", @@ -1379,7 +1380,7 @@ def testconn(self) -> FlaskResponse: # pylint: disable=no-self-use ), 400, ) - except ArgumentError: + except DatabaseInvalidError: logger.info("Invalid URI") return json_error_response( _( @@ -1586,16 +1587,24 @@ def fave_dashboards(self, user_id: int) -> FlaskResponse: @event_logger.log_this @expose("/created_dashboards//", methods=["GET"]) def created_dashboards(self, user_id: int) -> FlaskResponse: + logger.warning( + "%s.created_dashboards " + "This API endpoint is deprecated and will be removed in version 3.0.0", + self.__class__.__name__, + ) + error_obj = self.get_user_activity_access_error(user_id) if error_obj: return error_obj - Dash = Dashboard qry = ( - db.session.query(Dash) + db.session.query(Dashboard) .filter( # pylint: disable=comparison-with-callable - or_(Dash.created_by_fk == user_id, Dash.changed_by_fk == user_id) + or_( + Dashboard.created_by_fk == user_id, + Dashboard.changed_by_fk == user_id, + ) ) - .order_by(Dash.changed_on.desc()) + .order_by(Dashboard.changed_on.desc()) ) payload = [ { @@ -1815,7 +1824,8 @@ def warm_up_cache( # pylint: disable=too-many-locals,no-self-use force=True, ) - g.form_data = form_data # pylint: disable=assigning-non-slot + # pylint: disable=assigning-non-slot + g.form_data = form_data payload = obj.get_payload() delattr(g, "form_data") error = payload["errors"] or None @@ -1916,6 +1926,8 @@ def dashboard( request.args.get(utils.ReservedUrlParameters.EDIT_MODE.value) == "true" ) + standalone_mode = ReservedUrlParameters.is_standalone_mode() + add_extra_log_payload( dashboard_id=dashboard.id, dashboard_version="v2", @@ -1934,6 +1946,7 @@ def dashboard( bootstrap_data=json.dumps( bootstrap_data, default=utils.pessimistic_json_iso_dttm_ser ), + standalone_mode=standalone_mode, ) @has_access @@ -2109,7 +2122,7 @@ def sqllab_viz(self) -> FlaskResponse: # pylint: disable=no-self-use column_name=column_name, filterable=True, groupby=True, - is_dttm=config_.get("is_date", False), + is_dttm=config_.get("is_dttm", False), type=config_.get("type", False), ) cols.append(col) @@ -2810,7 +2823,7 @@ def schemas_access_for_file_upload(self) -> FlaskResponse: get the schema access control settings for file upload in this database """ if not request.args.get("db_id"): - return json_error_response("No database is allowed for your csv upload") + return json_error_response("No database is allowed for your file upload") db_id = int(request.args["db_id"]) database = db.session.query(Database).filter_by(id=db_id).one() diff --git a/superset/views/database/mixins.py b/superset/views/database/mixins.py index d5a5157ef4f7b..f6f7f1115e201 100644 --- a/superset/views/database/mixins.py +++ b/superset/views/database/mixins.py @@ -19,10 +19,10 @@ from flask import Markup from flask_babel import lazy_gettext as _ from sqlalchemy import MetaData -from sqlalchemy.engine.url import make_url from superset import app, security_manager from superset.databases.filters import DatabaseFilter +from superset.databases.utils import make_url_safe from superset.exceptions import SupersetException from superset.models.core import Database from superset.security.analytics_db_safety import check_sqlalchemy_uri @@ -209,7 +209,7 @@ class DatabaseMixin: def _pre_add_update(self, database: Database) -> None: if app.config["PREVENT_UNSAFE_DB_CONNECTIONS"]: - check_sqlalchemy_uri(make_url(database.sqlalchemy_uri)) + check_sqlalchemy_uri(make_url_safe(database.sqlalchemy_uri)) self.check_extra(database) self.check_encrypted_extra(database) if database.server_cert: diff --git a/superset/views/database/validators.py b/superset/views/database/validators.py index 2b2aa264407ef..93723ac38b8f2 100644 --- a/superset/views/database/validators.py +++ b/superset/views/database/validators.py @@ -19,10 +19,10 @@ from flask_babel import lazy_gettext as _ from marshmallow import ValidationError -from sqlalchemy.engine.url import make_url -from sqlalchemy.exc import ArgumentError from superset import security_manager +from superset.databases.commands.exceptions import DatabaseInvalidError +from superset.databases.utils import make_url_safe from superset.models.core import Database @@ -33,8 +33,8 @@ def sqlalchemy_uri_validator( Check if a user has submitted a valid SQLAlchemy URI """ try: - make_url(uri.strip()) - except (ArgumentError, AttributeError) as ex: + make_url_safe(uri.strip()) + except DatabaseInvalidError as ex: raise exception( [ _( diff --git a/superset/views/sql_lab.py b/superset/views/sql_lab.py index 0e17f46f16f07..60732fbcd1adb 100644 --- a/superset/views/sql_lab.py +++ b/superset/views/sql_lab.py @@ -14,6 +14,8 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +import logging + import simplejson as json from flask import g, redirect, request, Response from flask_appbuilder import expose @@ -30,6 +32,8 @@ from .base import BaseSupersetView, DeleteMixin, json_success, SupersetModelView +logger = logging.getLogger(__name__) + class SavedQueryView(SupersetModelView, DeleteMixin): datamodel = SQLAInterface(SavedQuery) @@ -318,4 +322,7 @@ class SqlLab(BaseSupersetView): @has_access def my_queries(self) -> FlaskResponse: # pylint: disable=no-self-use """Assigns a list of found users to the given role.""" + logger.warning( + "This endpoint is deprecated and will be removed in the next major release" + ) return redirect("/savedqueryview/list/?_flt_0_user={}".format(g.user.get_id())) diff --git a/tests/integration_tests/access_tests.py b/tests/integration_tests/access_tests.py index 13febbd413c9d..abefc58c9bc65 100644 --- a/tests/integration_tests/access_tests.py +++ b/tests/integration_tests/access_tests.py @@ -374,6 +374,7 @@ def test_clean_requests_after_schema_grant(self): .filter_by(table_name="wb_health_population") .first() ) + original_schema = ds.schema ds.schema = "temp_schema" security_manager.add_permission_view_menu("schema_access", ds.schema_perm) @@ -394,13 +395,7 @@ def test_clean_requests_after_schema_grant(self): gamma_user = security_manager.find_user(username="gamma") gamma_user.roles.remove(security_manager.find_role(SCHEMA_ACCESS_ROLE)) - ds = ( - session.query(SqlaTable) - .filter_by(table_name="wb_health_population") - .first() - ) - ds.schema = None - + ds.schema = original_schema session.commit() @mock.patch("superset.utils.core.send_mime_email") diff --git a/tests/integration_tests/celery_tests.py b/tests/integration_tests/celery_tests.py index 802684ba3bd07..3d4ba5e901f08 100644 --- a/tests/integration_tests/celery_tests.py +++ b/tests/integration_tests/celery_tests.py @@ -130,8 +130,8 @@ def cta_result(ctas_method: CtasMethod): if backend() != "presto": return [], [] if ctas_method == CtasMethod.TABLE: - return [{"rows": 1}], [{"name": "rows", "type": "BIGINT", "is_date": False}] - return [{"result": True}], [{"name": "result", "type": "BOOLEAN", "is_date": False}] + return [{"rows": 1}], [{"name": "rows", "type": "BIGINT", "is_dttm": False}] + return [{"result": True}], [{"name": "result", "type": "BOOLEAN", "is_dttm": False}] # TODO(bkyryliuk): quote table and schema names for all databases diff --git a/tests/integration_tests/charts/api_tests.py b/tests/integration_tests/charts/api_tests.py index 3c92caceead73..6b8d625d567e3 100644 --- a/tests/integration_tests/charts/api_tests.py +++ b/tests/integration_tests/charts/api_tests.py @@ -17,11 +17,9 @@ # isort:skip_file """Unit tests for Superset""" import json -from datetime import datetime from io import BytesIO from zipfile import is_zipfile, ZipFile -import humanize import prison import pytest import yaml @@ -803,7 +801,6 @@ def test_get_charts_changed_on(self): Dashboard API: Test get charts changed on """ admin = self.get_user("admin") - start_changed_on = datetime.now() chart = self.insert_chart("foo_a", [admin.id], 1, description="ZY_bar") self.login(username="admin") @@ -817,9 +814,9 @@ def test_get_charts_changed_on(self): rv = self.get_assert_metric(uri, "get_list") self.assertEqual(rv.status_code, 200) data = json.loads(rv.data.decode("utf-8")) - self.assertEqual( - data["result"][0]["changed_on_delta_humanized"], - humanize.naturaltime(datetime.now() - start_changed_on), + assert data["result"][0]["changed_on_delta_humanized"] in ( + "now", + "a second ago", ) # rollback changes diff --git a/tests/integration_tests/commands_test.py b/tests/integration_tests/commands_test.py index 5ff18b02a93e4..77fbad05f3a39 100644 --- a/tests/integration_tests/commands_test.py +++ b/tests/integration_tests/commands_test.py @@ -16,11 +16,11 @@ # under the License. import copy import json -from unittest.mock import patch import yaml +from flask import g -from superset import db, security_manager +from superset import db from superset.commands.exceptions import CommandInvalidError from superset.commands.importers.v1.assets import ImportAssetsCommand from superset.commands.importers.v1.utils import is_valid_config @@ -58,10 +58,13 @@ def test_is_valid_config(self): class TestImportAssetsCommand(SupersetTestCase): - @patch("superset.dashboards.commands.importers.v1.utils.g") - def test_import_assets(self, mock_g): + def setUp(self): + user = self.get_user("admin") + self.user = user + setattr(g, "user", user) + + def test_import_assets(self): """Test that we can import multiple assets""" - mock_g.user = security_manager.find_user("admin") contents = { "metadata.yaml": yaml.safe_dump(metadata_config), "databases/imported_database.yaml": yaml.safe_dump(database_config), @@ -141,7 +144,7 @@ def test_import_assets(self, mock_g): database = dataset.database assert str(database.uuid) == database_config["uuid"] - assert dashboard.owners == [mock_g.user] + assert dashboard.owners == [self.user] dashboard.owners = [] chart.owners = [] @@ -153,11 +156,8 @@ def test_import_assets(self, mock_g): db.session.delete(database) db.session.commit() - @patch("superset.dashboards.commands.importers.v1.utils.g") - def test_import_v1_dashboard_overwrite(self, mock_g): + def test_import_v1_dashboard_overwrite(self): """Test that assets can be overwritten""" - mock_g.user = security_manager.find_user("admin") - contents = { "metadata.yaml": yaml.safe_dump(metadata_config), "databases/imported_database.yaml": yaml.safe_dump(database_config), diff --git a/tests/integration_tests/dashboard_tests.py b/tests/integration_tests/dashboard_tests.py index 63453d85ee4fc..3ad9b07e29c14 100644 --- a/tests/integration_tests/dashboard_tests.py +++ b/tests/integration_tests/dashboard_tests.py @@ -18,6 +18,7 @@ """Unit tests for Superset""" from datetime import datetime import json +import re import unittest from random import random @@ -139,9 +140,20 @@ def test_new_dashboard(self): self.login(username="admin") dash_count_before = db.session.query(func.count(Dashboard.id)).first()[0] url = "/dashboard/new/" - resp = self.get_resp(url) + response = self.client.get(url, follow_redirects=False) dash_count_after = db.session.query(func.count(Dashboard.id)).first()[0] self.assertEqual(dash_count_before + 1, dash_count_after) + group = re.match( + r"http:\/\/localhost\/superset\/dashboard\/([0-9]*)\/\?edit=true", + response.headers["Location"], + ) + assert group is not None + + # Cleanup + created_dashboard_id = int(group[1]) + created_dashboard = db.session.query(Dashboard).get(created_dashboard_id) + db.session.delete(created_dashboard) + db.session.commit() @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") def test_save_dash(self, username="admin"): diff --git a/tests/integration_tests/dashboards/api_tests.py b/tests/integration_tests/dashboards/api_tests.py index a179fa7c7e453..a027dcffae604 100644 --- a/tests/integration_tests/dashboards/api_tests.py +++ b/tests/integration_tests/dashboards/api_tests.py @@ -18,6 +18,7 @@ """Unit tests for Superset""" import json from io import BytesIO +from time import sleep from typing import List, Optional from unittest.mock import patch from zipfile import is_zipfile, ZipFile @@ -27,7 +28,6 @@ import pytest import prison import yaml -from sqlalchemy.sql import func from freezegun import freeze_time from sqlalchemy import and_ @@ -160,6 +160,27 @@ def create_dashboards(self): db.session.delete(fav_dashboard) db.session.commit() + @pytest.fixture() + def create_created_by_admin_dashboards(self): + with self.create_app().app_context(): + dashboards = [] + admin = self.get_user("admin") + for cx in range(2): + dashboard = self.insert_dashboard( + f"create_title{cx}", + f"create_slug{cx}", + [admin.id], + created_by=admin, + ) + sleep(1) + dashboards.append(dashboard) + + yield dashboards + + for dashboard in dashboards: + db.session.delete(dashboard) + db.session.commit() + @pytest.fixture() def create_dashboard_with_report(self): with self.create_app().app_context(): @@ -674,7 +695,41 @@ def test_gets_not_certified_dashboards_filter(self): rv = self.get_assert_metric(uri, "get_list") self.assertEqual(rv.status_code, 200) data = json.loads(rv.data.decode("utf-8")) - self.assertEqual(data["count"], 6) + self.assertEqual(data["count"], 5) + + @pytest.mark.usefixtures("create_created_by_admin_dashboards") + def test_get_dashboards_created_by_me(self): + """ + Dashboard API: Test get dashboards created by current user + """ + query = { + "columns": ["created_on_delta_humanized", "dashboard_title", "url"], + "filters": [{"col": "created_by", "opr": "created_by_me", "value": "me"}], + "order_column": "changed_on", + "order_direction": "desc", + "page": 0, + "page_size": 100, + } + uri = f"api/v1/dashboard/?q={prison.dumps(query)}" + self.login(username="admin") + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert rv.status_code == 200 + assert len(data["result"]) == 2 + assert list(data["result"][0].keys()) == query["columns"] + expected_results = [ + { + "dashboard_title": "create_title1", + "url": "/superset/dashboard/create_slug1/", + }, + { + "dashboard_title": "create_title0", + "url": "/superset/dashboard/create_slug0/", + }, + ] + for idx, response_item in enumerate(data["result"]): + for key, value in expected_results[idx].items(): + assert response_item[key] == value def create_dashboard_import(self): buf = BytesIO() @@ -1370,6 +1425,7 @@ def test_update_dashboard_not_owned(self): "load_world_bank_dashboard_with_slices", "load_birth_names_dashboard_with_slices", ) + @freeze_time("2022-01-01") def test_export(self): """ Dashboard API: Test dashboard export @@ -1740,6 +1796,8 @@ def test_embedded_dashboards(self): self.assertNotEqual(result["uuid"], "") self.assertEqual(result["allowed_domains"], allowed_domains) + db.session.expire_all() + # get returns value resp = self.get_assert_metric(uri, "get_embedded") self.assertEqual(resp.status_code, 200) @@ -1754,9 +1812,13 @@ def test_embedded_dashboards(self): # put succeeds and returns value resp = self.post_assert_metric(uri, {"allowed_domains": []}, "set_embedded") self.assertEqual(resp.status_code, 200) + result = json.loads(resp.data.decode("utf-8"))["result"] + self.assertEqual(resp.status_code, 200) self.assertIsNotNone(result["uuid"]) self.assertNotEqual(result["uuid"], "") - self.assertEqual(result["allowed_domains"], allowed_domains) + self.assertEqual(result["allowed_domains"], []) + + db.session.expire_all() # get returns changed value resp = self.get_assert_metric(uri, "get_embedded") @@ -1769,6 +1831,8 @@ def test_embedded_dashboards(self): resp = self.delete_assert_metric(uri, "delete_embedded") self.assertEqual(resp.status_code, 200) + db.session.expire_all() + # get returns 404 resp = self.get_assert_metric(uri, "get_embedded") self.assertEqual(resp.status_code, 404) diff --git a/tests/integration_tests/databases/api_tests.py b/tests/integration_tests/databases/api_tests.py index 4f29600bdabb7..70640728ac352 100644 --- a/tests/integration_tests/databases/api_tests.py +++ b/tests/integration_tests/databases/api_tests.py @@ -80,6 +80,7 @@ def insert_database( encrypted_extra: str = "", server_cert: str = "", expose_in_sqllab: bool = False, + allow_file_upload: bool = False, ) -> Database: database = Database( database_name=database_name, @@ -88,6 +89,7 @@ def insert_database( encrypted_extra=encrypted_extra, server_cert=server_cert, expose_in_sqllab=expose_in_sqllab, + allow_file_upload=allow_file_upload, ) db.session.add(database) db.session.commit() @@ -864,6 +866,362 @@ def test_get_select_star_not_found_table(self): # TODO(bkyryliuk): investigate why presto returns 500 self.assertEqual(rv.status_code, 404 if example_db.backend != "presto" else 500) + def test_get_allow_file_upload_filter(self): + """ + Database API: Test filter for allow file upload checks for schemas + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": ["public"], + } + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=True, + ) + db.session.commit() + yield database + + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 1 + db.session.delete(database) + db.session.commit() + + def test_get_allow_file_upload_filter_no_schema(self): + """ + Database API: Test filter for allow file upload checks for schemas. + This test has allow_file_upload but no schemas. + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": [], + } + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=True, + ) + db.session.commit() + yield database + + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 0 + db.session.delete(database) + db.session.commit() + + def test_get_allow_file_upload_filter_allow_file_false(self): + """ + Database API: Test filter for allow file upload checks for schemas. + This has a schema but does not allow_file_upload + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": ["public"], + } + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=False, + ) + db.session.commit() + yield database + + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 0 + db.session.delete(database) + db.session.commit() + + def test_get_allow_file_upload_false(self): + """ + Database API: Test filter for allow file upload checks for schemas. + Both databases have false allow_file_upload + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": [], + } + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=False, + ) + db.session.commit() + yield database + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 0 + db.session.delete(database) + db.session.commit() + + def test_get_allow_file_upload_false_no_extra(self): + """ + Database API: Test filter for allow file upload checks for schemas. + Both databases have false allow_file_upload + """ + with self.create_app().app_context(): + example_db = get_example_database() + + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + allow_file_upload=False, + ) + db.session.commit() + yield database + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 0 + db.session.delete(database) + db.session.commit() + + def mock_csv_function(d, user): + return d.get_all_schema_names() + + @mock.patch( + "superset.views.core.app.config", + {**app.config, "ALLOWED_USER_CSV_SCHEMA_FUNC": mock_csv_function}, + ) + def test_get_allow_file_upload_true_csv(self): + """ + Database API: Test filter for allow file upload checks for schemas. + Both databases have false allow_file_upload + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": [], + } + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=True, + ) + db.session.commit() + yield database + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 1 + db.session.delete(database) + db.session.commit() + + def mock_empty_csv_function(d, user): + return [] + + @mock.patch( + "superset.views.core.app.config", + {**app.config, "ALLOWED_USER_CSV_SCHEMA_FUNC": mock_empty_csv_function}, + ) + def test_get_allow_file_upload_false_csv(self): + """ + Database API: Test filter for allow file upload checks for schemas. + Both databases have false allow_file_upload + """ + with self.create_app().app_context(): + self.login(username="admin") + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 1 + + def test_get_allow_file_upload_filter_no_permission(self): + """ + Database API: Test filter for allow file upload checks for schemas + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": ["public"], + } + self.login(username="gamma") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=True, + ) + db.session.commit() + yield database + + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 0 + db.session.delete(database) + db.session.commit() + + def test_get_allow_file_upload_filter_with_permission(self): + """ + Database API: Test filter for allow file upload checks for schemas + """ + with self.create_app().app_context(): + main_db = get_main_database() + main_db.allow_file_upload = True + session = db.session + table = SqlaTable( + schema="public", + table_name="ab_permission", + database=get_main_database(), + ) + + session.add(table) + session.commit() + tmp_table_perm = security_manager.find_permission_view_menu( + "datasource_access", table.get_perm() + ) + gamma_role = security_manager.find_role("Gamma") + security_manager.add_permission_role(gamma_role, tmp_table_perm) + + self.login(username="gamma") + + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 1 + + # rollback changes + security_manager.del_permission_role(gamma_role, tmp_table_perm) + db.session.delete(table) + db.session.delete(main_db) + db.session.commit() + def test_database_schemas(self): """ Database API: Test database schemas diff --git a/tests/integration_tests/db_engine_specs/pinot_tests.py b/tests/integration_tests/db_engine_specs/pinot_tests.py index 803dd67cbacfa..c6e364a8ea5fe 100644 --- a/tests/integration_tests/db_engine_specs/pinot_tests.py +++ b/tests/integration_tests/db_engine_specs/pinot_tests.py @@ -45,6 +45,19 @@ def test_pinot_time_expression_simple_date_format_1d_grain(self): ), ) + def test_pinot_time_expression_simple_date_format_10m_grain(self): + col = column("tstamp") + expr = PinotEngineSpec.get_timestamp_expr(col, "%Y-%m-%d %H:%M:%S", "PT10M") + result = str(expr.compile()) + self.assertEqual( + result, + ( + "DATETIMECONVERT(tstamp, " + + "'1:SECONDS:SIMPLE_DATE_FORMAT:yyyy-MM-dd HH:mm:ss', " + + "'1:SECONDS:SIMPLE_DATE_FORMAT:yyyy-MM-dd HH:mm:ss', '10:MINUTES')" + ), + ) + def test_pinot_time_expression_simple_date_format_1w_grain(self): col = column("tstamp") expr = PinotEngineSpec.get_timestamp_expr(col, "%Y-%m-%d %H:%M:%S", "P1W") diff --git a/tests/integration_tests/db_engine_specs/presto_tests.py b/tests/integration_tests/db_engine_specs/presto_tests.py index 558f4322a0e5d..17c7c2d900e7f 100644 --- a/tests/integration_tests/db_engine_specs/presto_tests.py +++ b/tests/integration_tests/db_engine_specs/presto_tests.py @@ -207,8 +207,8 @@ def test_presto_get_fields(self): ) def test_presto_expand_data_with_simple_structural_columns(self): cols = [ - {"name": "row_column", "type": "ROW(NESTED_OBJ VARCHAR)"}, - {"name": "array_column", "type": "ARRAY(BIGINT)"}, + {"name": "row_column", "type": "ROW(NESTED_OBJ VARCHAR)", "is_dttm": False}, + {"name": "array_column", "type": "ARRAY(BIGINT)", "is_dttm": False}, ] data = [ {"row_column": ["a"], "array_column": [1, 2, 3]}, @@ -218,9 +218,9 @@ def test_presto_expand_data_with_simple_structural_columns(self): cols, data ) expected_cols = [ - {"name": "row_column", "type": "ROW(NESTED_OBJ VARCHAR)"}, - {"name": "row_column.nested_obj", "type": "VARCHAR"}, - {"name": "array_column", "type": "ARRAY(BIGINT)"}, + {"name": "row_column", "type": "ROW(NESTED_OBJ VARCHAR)", "is_dttm": False}, + {"name": "row_column.nested_obj", "type": "VARCHAR", "is_dttm": False}, + {"name": "array_column", "type": "ARRAY(BIGINT)", "is_dttm": False}, ] expected_data = [ @@ -232,7 +232,9 @@ def test_presto_expand_data_with_simple_structural_columns(self): {"array_column": 6, "row_column": "", "row_column.nested_obj": ""}, ] - expected_expanded_cols = [{"name": "row_column.nested_obj", "type": "VARCHAR"}] + expected_expanded_cols = [ + {"name": "row_column.nested_obj", "type": "VARCHAR", "is_dttm": False} + ] self.assertEqual(actual_cols, expected_cols) self.assertEqual(actual_data, expected_data) self.assertEqual(actual_expanded_cols, expected_expanded_cols) @@ -247,6 +249,7 @@ def test_presto_expand_data_with_complex_row_columns(self): { "name": "row_column", "type": "ROW(NESTED_OBJ1 VARCHAR, NESTED_ROW ROW(NESTED_OBJ2 VARCHAR))", + "is_dttm": False, } ] data = [{"row_column": ["a1", ["a2"]]}, {"row_column": ["b1", ["b2"]]}] @@ -257,10 +260,19 @@ def test_presto_expand_data_with_complex_row_columns(self): { "name": "row_column", "type": "ROW(NESTED_OBJ1 VARCHAR, NESTED_ROW ROW(NESTED_OBJ2 VARCHAR))", + "is_dttm": False, + }, + {"name": "row_column.nested_obj1", "type": "VARCHAR", "is_dttm": False}, + { + "name": "row_column.nested_row", + "type": "ROW(NESTED_OBJ2 VARCHAR)", + "is_dttm": False, + }, + { + "name": "row_column.nested_row.nested_obj2", + "type": "VARCHAR", + "is_dttm": False, }, - {"name": "row_column.nested_obj1", "type": "VARCHAR"}, - {"name": "row_column.nested_row", "type": "ROW(NESTED_OBJ2 VARCHAR)"}, - {"name": "row_column.nested_row.nested_obj2", "type": "VARCHAR"}, ] expected_data = [ { @@ -278,9 +290,17 @@ def test_presto_expand_data_with_complex_row_columns(self): ] expected_expanded_cols = [ - {"name": "row_column.nested_obj1", "type": "VARCHAR"}, - {"name": "row_column.nested_row", "type": "ROW(NESTED_OBJ2 VARCHAR)"}, - {"name": "row_column.nested_row.nested_obj2", "type": "VARCHAR"}, + {"name": "row_column.nested_obj1", "type": "VARCHAR", "is_dttm": False}, + { + "name": "row_column.nested_row", + "type": "ROW(NESTED_OBJ2 VARCHAR)", + "is_dttm": False, + }, + { + "name": "row_column.nested_row.nested_obj2", + "type": "VARCHAR", + "is_dttm": False, + }, ] self.assertEqual(actual_cols, expected_cols) self.assertEqual(actual_data, expected_data) @@ -296,6 +316,7 @@ def test_presto_expand_data_with_complex_row_columns_and_null_values(self): { "name": "row_column", "type": "ROW(NESTED_ROW ROW(NESTED_OBJ VARCHAR))", + "is_dttm": False, } ] data = [ @@ -311,9 +332,18 @@ def test_presto_expand_data_with_complex_row_columns_and_null_values(self): { "name": "row_column", "type": "ROW(NESTED_ROW ROW(NESTED_OBJ VARCHAR))", + "is_dttm": False, + }, + { + "name": "row_column.nested_row", + "type": "ROW(NESTED_OBJ VARCHAR)", + "is_dttm": False, + }, + { + "name": "row_column.nested_row.nested_obj", + "type": "VARCHAR", + "is_dttm": False, }, - {"name": "row_column.nested_row", "type": "ROW(NESTED_OBJ VARCHAR)"}, - {"name": "row_column.nested_row.nested_obj", "type": "VARCHAR"}, ] expected_data = [ { @@ -339,8 +369,16 @@ def test_presto_expand_data_with_complex_row_columns_and_null_values(self): ] expected_expanded_cols = [ - {"name": "row_column.nested_row", "type": "ROW(NESTED_OBJ VARCHAR)"}, - {"name": "row_column.nested_row.nested_obj", "type": "VARCHAR"}, + { + "name": "row_column.nested_row", + "type": "ROW(NESTED_OBJ VARCHAR)", + "is_dttm": False, + }, + { + "name": "row_column.nested_row.nested_obj", + "type": "VARCHAR", + "is_dttm": False, + }, ] self.assertEqual(actual_cols, expected_cols) self.assertEqual(actual_data, expected_data) @@ -353,10 +391,11 @@ def test_presto_expand_data_with_complex_row_columns_and_null_values(self): ) def test_presto_expand_data_with_complex_array_columns(self): cols = [ - {"name": "int_column", "type": "BIGINT"}, + {"name": "int_column", "type": "BIGINT", "is_dttm": False}, { "name": "array_column", "type": "ARRAY(ROW(NESTED_ARRAY ARRAY(ROW(NESTED_OBJ VARCHAR))))", + "is_dttm": False, }, ] data = [ @@ -367,16 +406,22 @@ def test_presto_expand_data_with_complex_array_columns(self): cols, data ) expected_cols = [ - {"name": "int_column", "type": "BIGINT"}, + {"name": "int_column", "type": "BIGINT", "is_dttm": False}, { "name": "array_column", "type": "ARRAY(ROW(NESTED_ARRAY ARRAY(ROW(NESTED_OBJ VARCHAR))))", + "is_dttm": False, }, { "name": "array_column.nested_array", "type": "ARRAY(ROW(NESTED_OBJ VARCHAR))", + "is_dttm": False, + }, + { + "name": "array_column.nested_array.nested_obj", + "type": "VARCHAR", + "is_dttm": False, }, - {"name": "array_column.nested_array.nested_obj", "type": "VARCHAR"}, ] expected_data = [ { @@ -432,8 +477,13 @@ def test_presto_expand_data_with_complex_array_columns(self): { "name": "array_column.nested_array", "type": "ARRAY(ROW(NESTED_OBJ VARCHAR))", + "is_dttm": False, + }, + { + "name": "array_column.nested_array.nested_obj", + "type": "VARCHAR", + "is_dttm": False, }, - {"name": "array_column.nested_array.nested_obj", "type": "VARCHAR"}, ] self.assertEqual(actual_cols, expected_cols) self.assertEqual(actual_data, expected_data) @@ -545,12 +595,12 @@ def test_query_cost_formatter(self): ) def test_presto_expand_data_array(self): cols = [ - {"name": "event_id", "type": "VARCHAR", "is_date": False}, - {"name": "timestamp", "type": "BIGINT", "is_date": False}, + {"name": "event_id", "type": "VARCHAR", "is_dttm": False}, + {"name": "timestamp", "type": "BIGINT", "is_dttm": False}, { "name": "user", "type": "ROW(ID BIGINT, FIRST_NAME VARCHAR, LAST_NAME VARCHAR)", - "is_date": False, + "is_dttm": False, }, ] data = [ @@ -564,16 +614,16 @@ def test_presto_expand_data_array(self): cols, data ) expected_cols = [ - {"name": "event_id", "type": "VARCHAR", "is_date": False}, - {"name": "timestamp", "type": "BIGINT", "is_date": False}, + {"name": "event_id", "type": "VARCHAR", "is_dttm": False}, + {"name": "timestamp", "type": "BIGINT", "is_dttm": False}, { "name": "user", "type": "ROW(ID BIGINT, FIRST_NAME VARCHAR, LAST_NAME VARCHAR)", - "is_date": False, + "is_dttm": False, }, - {"name": "user.id", "type": "BIGINT"}, - {"name": "user.first_name", "type": "VARCHAR"}, - {"name": "user.last_name", "type": "VARCHAR"}, + {"name": "user.id", "type": "BIGINT", "is_dttm": False}, + {"name": "user.first_name", "type": "VARCHAR", "is_dttm": False}, + {"name": "user.last_name", "type": "VARCHAR", "is_dttm": False}, ] expected_data = [ { @@ -586,9 +636,9 @@ def test_presto_expand_data_array(self): } ] expected_expanded_cols = [ - {"name": "user.id", "type": "BIGINT"}, - {"name": "user.first_name", "type": "VARCHAR"}, - {"name": "user.last_name", "type": "VARCHAR"}, + {"name": "user.id", "type": "BIGINT", "is_dttm": False}, + {"name": "user.first_name", "type": "VARCHAR", "is_dttm": False}, + {"name": "user.last_name", "type": "VARCHAR", "is_dttm": False}, ] self.assertEqual(actual_cols, expected_cols) diff --git a/tests/integration_tests/druid_tests.py b/tests/integration_tests/druid_tests.py index a9c787ecc8a8d..66f5cc7244fc5 100644 --- a/tests/integration_tests/druid_tests.py +++ b/tests/integration_tests/druid_tests.py @@ -290,7 +290,7 @@ def check(): .one() ) # columns and metrics are not deleted if config is changed as - # user could define his own dimensions / metrics and want to keep them + # user could define their own dimensions / metrics and want to keep them assert set([c.column_name for c in druid_ds.columns]) == set( ["affiliate_id", "campaign", "first_seen", "second_seen"] ) diff --git a/tests/integration_tests/embedded/api_tests.py b/tests/integration_tests/embedded/api_tests.py new file mode 100644 index 0000000000000..8f3950fcf5462 --- /dev/null +++ b/tests/integration_tests/embedded/api_tests.py @@ -0,0 +1,53 @@ +# 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 +"""Tests for security api methods""" +from unittest import mock + +import pytest + +from superset import db +from superset.embedded.dao import EmbeddedDAO +from superset.models.dashboard import Dashboard +from tests.integration_tests.base_tests import SupersetTestCase +from tests.integration_tests.fixtures.birth_names_dashboard import ( + load_birth_names_dashboard_with_slices, + load_birth_names_data, +) + + +class TestEmbeddedDashboardApi(SupersetTestCase): + resource_name = "embedded_dashboard" + + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") + @mock.patch.dict( + "superset.extensions.feature_flag_manager._feature_flags", + EMBEDDED_SUPERSET=True, + ) + def test_get_embedded_dashboard(self): + self.login("admin") + self.dash = db.session.query(Dashboard).filter_by(slug="births").first() + self.embedded = EmbeddedDAO.upsert(self.dash, []) + uri = f"api/v1/{self.resource_name}/{self.embedded.uuid}" + response = self.client.get(uri) + self.assert200(response) + + def test_get_embedded_dashboard_non_found(self): + self.login("admin") + uri = f"api/v1/{self.resource_name}/bad-uuid" + response = self.client.get(uri) + self.assert404(response) diff --git a/tests/integration_tests/fixtures/world_bank_dashboard.py b/tests/integration_tests/fixtures/world_bank_dashboard.py index 1ac1706a9dc05..e767036b7d857 100644 --- a/tests/integration_tests/fixtures/world_bank_dashboard.py +++ b/tests/integration_tests/fixtures/world_bank_dashboard.py @@ -111,11 +111,10 @@ def _commit_slices(slices: List[Slice]): def _create_world_bank_dashboard(table: SqlaTable, slices: List[Slice]) -> Dashboard: + from superset.examples.helpers import update_slice_ids from superset.examples.world_bank import dashboard_positions pos = dashboard_positions - from superset.examples.helpers import update_slice_ids - update_slice_ids(pos, slices) table.fetch_metadata() diff --git a/tests/integration_tests/jinja_context_tests.py b/tests/integration_tests/jinja_context_tests.py deleted file mode 100644 index 924e93e17e25c..0000000000000 --- a/tests/integration_tests/jinja_context_tests.py +++ /dev/null @@ -1,422 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -import json -from datetime import datetime -from typing import Any -from unittest import mock - -import pytest -from sqlalchemy.dialects.postgresql import dialect - -import superset.utils.database -import tests.integration_tests.test_app -from superset import app -from superset.exceptions import SupersetTemplateException -from superset.jinja_context import ExtraCache, get_template_processor, safe_proxy -from superset.utils import core as utils -from tests.integration_tests.base_tests import SupersetTestCase - - -class TestJinja2Context(SupersetTestCase): - def test_filter_values_default(self) -> None: - with app.test_request_context(): - cache = ExtraCache() - self.assertEqual(cache.filter_values("name", "foo"), ["foo"]) - self.assertEqual(cache.removed_filters, list()) - - def test_filter_values_remove_not_present(self) -> None: - with app.test_request_context(): - cache = ExtraCache() - self.assertEqual(cache.filter_values("name", remove_filter=True), []) - self.assertEqual(cache.removed_filters, list()) - - def test_get_filters_remove_not_present(self) -> None: - with app.test_request_context(): - cache = ExtraCache() - self.assertEqual(cache.get_filters("name", remove_filter=True), []) - self.assertEqual(cache.removed_filters, list()) - - def test_filter_values_no_default(self) -> None: - with app.test_request_context(): - cache = ExtraCache() - self.assertEqual(cache.filter_values("name"), []) - - def test_filter_values_adhoc_filters(self) -> None: - with app.test_request_context( - data={ - "form_data": json.dumps( - { - "adhoc_filters": [ - { - "clause": "WHERE", - "comparator": "foo", - "expressionType": "SIMPLE", - "operator": "in", - "subject": "name", - } - ], - } - ) - } - ): - cache = ExtraCache() - self.assertEqual(cache.filter_values("name"), ["foo"]) - self.assertEqual(cache.applied_filters, ["name"]) - - with app.test_request_context( - data={ - "form_data": json.dumps( - { - "adhoc_filters": [ - { - "clause": "WHERE", - "comparator": ["foo", "bar"], - "expressionType": "SIMPLE", - "operator": "in", - "subject": "name", - } - ], - } - ) - } - ): - cache = ExtraCache() - self.assertEqual(cache.filter_values("name"), ["foo", "bar"]) - self.assertEqual(cache.applied_filters, ["name"]) - - def test_get_filters_adhoc_filters(self) -> None: - with app.test_request_context( - data={ - "form_data": json.dumps( - { - "adhoc_filters": [ - { - "clause": "WHERE", - "comparator": "foo", - "expressionType": "SIMPLE", - "operator": "in", - "subject": "name", - } - ], - } - ) - } - ): - cache = ExtraCache() - self.assertEqual( - cache.get_filters("name"), [{"op": "IN", "col": "name", "val": ["foo"]}] - ) - self.assertEqual(cache.removed_filters, list()) - self.assertEqual(cache.applied_filters, ["name"]) - - with app.test_request_context( - data={ - "form_data": json.dumps( - { - "adhoc_filters": [ - { - "clause": "WHERE", - "comparator": ["foo", "bar"], - "expressionType": "SIMPLE", - "operator": "in", - "subject": "name", - } - ], - } - ) - } - ): - cache = ExtraCache() - self.assertEqual( - cache.get_filters("name"), - [{"op": "IN", "col": "name", "val": ["foo", "bar"]}], - ) - self.assertEqual(cache.removed_filters, list()) - - with app.test_request_context( - data={ - "form_data": json.dumps( - { - "adhoc_filters": [ - { - "clause": "WHERE", - "comparator": ["foo", "bar"], - "expressionType": "SIMPLE", - "operator": "in", - "subject": "name", - } - ], - } - ) - } - ): - cache = ExtraCache() - self.assertEqual( - cache.get_filters("name", remove_filter=True), - [{"op": "IN", "col": "name", "val": ["foo", "bar"]}], - ) - self.assertEqual(cache.removed_filters, ["name"]) - self.assertEqual(cache.applied_filters, ["name"]) - - def test_filter_values_extra_filters(self) -> None: - with app.test_request_context( - data={ - "form_data": json.dumps( - {"extra_filters": [{"col": "name", "op": "in", "val": "foo"}]} - ) - } - ): - cache = ExtraCache() - self.assertEqual(cache.filter_values("name"), ["foo"]) - self.assertEqual(cache.applied_filters, ["name"]) - - def test_url_param_default(self) -> None: - with app.test_request_context(): - cache = ExtraCache() - self.assertEqual(cache.url_param("foo", "bar"), "bar") - - def test_url_param_no_default(self) -> None: - with app.test_request_context(): - cache = ExtraCache() - self.assertEqual(cache.url_param("foo"), None) - - def test_url_param_query(self) -> None: - with app.test_request_context(query_string={"foo": "bar"}): - cache = ExtraCache() - self.assertEqual(cache.url_param("foo"), "bar") - - def test_url_param_form_data(self) -> None: - with app.test_request_context( - query_string={"form_data": json.dumps({"url_params": {"foo": "bar"}})} - ): - cache = ExtraCache() - self.assertEqual(cache.url_param("foo"), "bar") - - def test_url_param_escaped_form_data(self) -> None: - with app.test_request_context( - query_string={"form_data": json.dumps({"url_params": {"foo": "O'Brien"}})} - ): - cache = ExtraCache(dialect=dialect()) - self.assertEqual(cache.url_param("foo"), "O''Brien") - - def test_url_param_escaped_default_form_data(self) -> None: - with app.test_request_context( - query_string={"form_data": json.dumps({"url_params": {"foo": "O'Brien"}})} - ): - cache = ExtraCache(dialect=dialect()) - self.assertEqual(cache.url_param("bar", "O'Malley"), "O''Malley") - - def test_url_param_unescaped_form_data(self) -> None: - with app.test_request_context( - query_string={"form_data": json.dumps({"url_params": {"foo": "O'Brien"}})} - ): - cache = ExtraCache(dialect=dialect()) - self.assertEqual(cache.url_param("foo", escape_result=False), "O'Brien") - - def test_url_param_unescaped_default_form_data(self) -> None: - with app.test_request_context( - query_string={"form_data": json.dumps({"url_params": {"foo": "O'Brien"}})} - ): - cache = ExtraCache(dialect=dialect()) - self.assertEqual( - cache.url_param("bar", "O'Malley", escape_result=False), "O'Malley" - ) - - def test_safe_proxy_primitive(self) -> None: - def func(input: Any) -> Any: - return input - - return_value = safe_proxy(func, "foo") - self.assertEqual("foo", return_value) - - def test_safe_proxy_dict(self) -> None: - def func(input: Any) -> Any: - return input - - return_value = safe_proxy(func, {"foo": "bar"}) - self.assertEqual({"foo": "bar"}, return_value) - - def test_safe_proxy_lambda(self) -> None: - def func(input: Any) -> Any: - return input - - with pytest.raises(SupersetTemplateException): - safe_proxy(func, lambda: "bar") - - def test_safe_proxy_nested_lambda(self) -> None: - def func(input: Any) -> Any: - return input - - with pytest.raises(SupersetTemplateException): - safe_proxy(func, {"foo": lambda: "bar"}) - - def test_process_template(self) -> None: - maindb = superset.utils.database.get_example_database() - sql = "SELECT '{{ 1+1 }}'" - tp = get_template_processor(database=maindb) - rendered = tp.process_template(sql) - self.assertEqual("SELECT '2'", rendered) - - def test_get_template_kwarg(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ foo }}" - tp = get_template_processor(database=maindb, foo="bar") - rendered = tp.process_template(s) - self.assertEqual("bar", rendered) - - def test_template_kwarg(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ foo }}" - tp = get_template_processor(database=maindb) - rendered = tp.process_template(s, foo="bar") - self.assertEqual("bar", rendered) - - def test_get_template_kwarg_dict(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ foo.bar }}" - tp = get_template_processor(database=maindb, foo={"bar": "baz"}) - rendered = tp.process_template(s) - self.assertEqual("baz", rendered) - - def test_template_kwarg_dict(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ foo.bar }}" - tp = get_template_processor(database=maindb) - rendered = tp.process_template(s, foo={"bar": "baz"}) - self.assertEqual("baz", rendered) - - def test_get_template_kwarg_lambda(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ foo() }}" - tp = get_template_processor(database=maindb, foo=lambda: "bar") - with pytest.raises(SupersetTemplateException): - tp.process_template(s) - - def test_template_kwarg_lambda(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ foo() }}" - tp = get_template_processor(database=maindb) - with pytest.raises(SupersetTemplateException): - tp.process_template(s, foo=lambda: "bar") - - def test_get_template_kwarg_module(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ dt(2017, 1, 1).isoformat() }}" - tp = get_template_processor(database=maindb, dt=datetime) - with pytest.raises(SupersetTemplateException): - tp.process_template(s) - - def test_template_kwarg_module(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ dt(2017, 1, 1).isoformat() }}" - tp = get_template_processor(database=maindb) - with pytest.raises(SupersetTemplateException): - tp.process_template(s, dt=datetime) - - def test_get_template_kwarg_nested_module(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ foo.dt }}" - tp = get_template_processor(database=maindb, foo={"dt": datetime}) - with pytest.raises(SupersetTemplateException): - tp.process_template(s) - - def test_template_kwarg_nested_module(self) -> None: - maindb = superset.utils.database.get_example_database() - s = "{{ foo.dt }}" - tp = get_template_processor(database=maindb) - with pytest.raises(SupersetTemplateException): - tp.process_template(s, foo={"bar": datetime}) - - @mock.patch("superset.jinja_context.HiveTemplateProcessor.latest_partition") - def test_template_hive(self, lp_mock) -> None: - lp_mock.return_value = "the_latest" - db = mock.Mock() - db.backend = "hive" - s = "{{ hive.latest_partition('my_table') }}" - tp = get_template_processor(database=db) - rendered = tp.process_template(s) - self.assertEqual("the_latest", rendered) - - @mock.patch("superset.jinja_context.context_addons") - def test_template_context_addons(self, addons_mock) -> None: - addons_mock.return_value = {"datetime": datetime} - maindb = superset.utils.database.get_example_database() - s = "SELECT '{{ datetime(2017, 1, 1).isoformat() }}'" - tp = get_template_processor(database=maindb) - rendered = tp.process_template(s) - self.assertEqual("SELECT '2017-01-01T00:00:00'", rendered) - - @mock.patch( - "tests.integration_tests.superset_test_custom_template_processors.datetime" - ) - def test_custom_process_template(self, mock_dt) -> None: - """Test macro defined in custom template processor works.""" - mock_dt.utcnow = mock.Mock(return_value=datetime(1970, 1, 1)) - db = mock.Mock() - db.backend = "db_for_macros_testing" - tp = get_template_processor(database=db) - - sql = "SELECT '$DATE()'" - rendered = tp.process_template(sql) - self.assertEqual("SELECT '{}'".format("1970-01-01"), rendered) - - sql = "SELECT '$DATE(1, 2)'" - rendered = tp.process_template(sql) - self.assertEqual("SELECT '{}'".format("1970-01-02"), rendered) - - def test_custom_get_template_kwarg(self) -> None: - """Test macro passed as kwargs when getting template processor - works in custom template processor.""" - db = mock.Mock() - db.backend = "db_for_macros_testing" - s = "$foo()" - tp = get_template_processor(database=db, foo=lambda: "bar") - rendered = tp.process_template(s) - self.assertEqual("bar", rendered) - - def test_custom_template_kwarg(self) -> None: - """Test macro passed as kwargs when processing template - works in custom template processor.""" - db = mock.Mock() - db.backend = "db_for_macros_testing" - s = "$foo()" - tp = get_template_processor(database=db) - rendered = tp.process_template(s, foo=lambda: "bar") - self.assertEqual("bar", rendered) - - def test_custom_template_processors_overwrite(self) -> None: - """Test template processor for presto gets overwritten by custom one.""" - db = mock.Mock() - db.backend = "db_for_macros_testing" - tp = get_template_processor(database=db) - - sql = "SELECT '{{ datetime(2017, 1, 1).isoformat() }}'" - rendered = tp.process_template(sql) - self.assertEqual(sql, rendered) - - sql = "SELECT '{{ DATE(1, 2) }}'" - rendered = tp.process_template(sql) - self.assertEqual(sql, rendered) - - def test_custom_template_processors_ignored(self) -> None: - """Test custom template processor is ignored for a difference backend - database.""" - maindb = superset.utils.database.get_example_database() - sql = "SELECT '$DATE()'" - tp = get_template_processor(database=maindb) - rendered = tp.process_template(sql) - assert sql == rendered diff --git a/tests/integration_tests/migrations/ad07e4fdbaba_rm_time_range_endpoints_from_qc_3__test.py b/tests/integration_tests/migrations/ad07e4fdbaba_rm_time_range_endpoints_from_qc_3__test.py new file mode 100644 index 0000000000000..f2abfa9766196 --- /dev/null +++ b/tests/integration_tests/migrations/ad07e4fdbaba_rm_time_range_endpoints_from_qc_3__test.py @@ -0,0 +1,135 @@ +# 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 json + +from superset.migrations.versions.ad07e4fdbaba_rm_time_range_endpoints_from_qc_3 import ( + Slice, + upgrade_slice, +) + +sample_query_context = { + "datasource": {"id": 27, "type": "table"}, + "force": False, + "queries": [ + { + "time_range": "No filter", + "filters": [], + "extras": { + "time_grain_sqla": "P1D", + "time_range_endpoints": ["inclusive", "exclusive"], + "having": "", + "having_druid": [], + "where": "", + }, + "applied_time_extras": {}, + "columns": ["a", "b"], + "orderby": [], + "annotation_layers": [], + "row_limit": 1000, + "timeseries_limit": 0, + "order_desc": True, + "url_params": {}, + "custom_params": {}, + "custom_form_data": {}, + "post_processing": [], + } + ], + "form_data": { + "viz_type": "table", + "datasource": "27__table", + "slice_id": 545, + "url_params": {}, + "time_grain_sqla": "P1D", + "time_range": "No filter", + "query_mode": "raw", + "groupby": [], + "metrics": [], + "all_columns": ["a", "b"], + "percent_metrics": [], + "adhoc_filters": [], + "order_by_cols": [], + "row_limit": 1000, + "server_page_length": 10, + "include_time": False, + "order_desc": True, + "table_timestamp_format": "smart_date", + "show_cell_bars": True, + "color_pn": True, + "extra_form_data": {}, + "force": False, + "result_format": "json", + "result_type": "full", + }, + "result_format": "json", + "result_type": "full", +} + + +sample_query_context = { + "datasource": {"id": 27, "type": "table"}, + "force": False, + "queries": [ + { + "time_range": "No filter", + "filters": [], + "extras": { + "time_grain_sqla": "P1D", + "time_range_endpoints": ["inclusive", "exclusive"], + "having": "", + "having_druid": [], + "where": "", + }, + "applied_time_extras": {}, + "columns": ["a", "b"], + "orderby": [], + "annotation_layers": [], + "row_limit": 1000, + "timeseries_limit": 0, + "order_desc": True, + "url_params": {}, + "custom_params": {}, + "custom_form_data": {}, + "post_processing": [], + } + ], + "form_data": { + "time_range_endpoints": ["inclusive", "exclusive"], + }, + "result_format": "json", + "result_type": "full", +} + + +def test_upgrade(): + slc = Slice(slice_name="FOO", query_context=json.dumps(sample_query_context)) + + upgrade_slice(slc) + + query_context = json.loads(slc.query_context) + queries = query_context.get("queries") + for q in queries: + extras = q.get("extras", {}) + assert "time_range_endpoints" not in extras + + form_data = query_context.get("form_data", {}) + assert "time_range_endpoints" not in form_data + + +def test_upgrade_bad_json(): + slc = Slice(slice_name="FOO", query_context="abc") + + assert None == upgrade_slice(slc) diff --git a/tests/integration_tests/migrations/f1410ed7ec95_tests.py b/tests/integration_tests/migrations/f1410ed7ec95_migrate_native_filters_to_new_schema__tests.py similarity index 100% rename from tests/integration_tests/migrations/f1410ed7ec95_tests.py rename to tests/integration_tests/migrations/f1410ed7ec95_migrate_native_filters_to_new_schema__tests.py diff --git a/tests/integration_tests/migration_tests.py b/tests/integration_tests/migrations/fb13d49b72f9_better_filters__tests.py similarity index 63% rename from tests/integration_tests/migration_tests.py rename to tests/integration_tests/migrations/fb13d49b72f9_better_filters__tests.py index 444aefbc36253..f1fb9d737664d 100644 --- a/tests/integration_tests/migration_tests.py +++ b/tests/integration_tests/migrations/fb13d49b72f9_better_filters__tests.py @@ -21,20 +21,17 @@ upgrade_slice, ) -from .base_tests import SupersetTestCase +def test_upgrade_slice(): + slc = Slice( + slice_name="FOO", + viz_type="filter_box", + params=json.dumps(dict(metric="foo", groupby=["bar"])), + ) + upgrade_slice(slc) + params = json.loads(slc.params) + assert "metric" not in params + assert "filter_configs" in params -class TestMigration(SupersetTestCase): - def test_upgrade_slice(self): - slc = Slice( - slice_name="FOO", - viz_type="filter_box", - params=json.dumps(dict(metric="foo", groupby=["bar"])), - ) - upgrade_slice(slc) - params = json.loads(slc.params) - self.assertNotIn("metric", params) - self.assertIn("filter_configs", params) - - cfg = params["filter_configs"][0] - self.assertEqual(cfg.get("metric"), "foo") + cfg = params["filter_configs"][0] + assert cfg.get("metric") == "foo" diff --git a/tests/integration_tests/migrations/fc3a3a8ff221_tests.py b/tests/integration_tests/migrations/fc3a3a8ff221_migrate_filter_sets_to_new_format__tests.py similarity index 100% rename from tests/integration_tests/migrations/fc3a3a8ff221_tests.py rename to tests/integration_tests/migrations/fc3a3a8ff221_migrate_filter_sets_to_new_format__tests.py diff --git a/tests/integration_tests/queries/api_tests.py b/tests/integration_tests/queries/api_tests.py index d086e1082108e..eaf4e00576573 100644 --- a/tests/integration_tests/queries/api_tests.py +++ b/tests/integration_tests/queries/api_tests.py @@ -210,7 +210,7 @@ def test_get_query_no_data_access(self): get_example_database().id, gamma2.id, gamma2_client_id ) - # Gamma1 user, only sees his own queries + # Gamma1 user, only sees their own queries self.login(username="gamma_1", password="password") uri = f"api/v1/query/{query_gamma2.id}" rv = self.client.get(uri) @@ -219,7 +219,7 @@ def test_get_query_no_data_access(self): rv = self.client.get(uri) self.assertEqual(rv.status_code, 200) - # Gamma2 user, only sees his own queries + # Gamma2 user, only sees their own queries self.logout() self.login(username="gamma_2", password="password") uri = f"api/v1/query/{query_gamma1.id}" diff --git a/tests/integration_tests/result_set_tests.py b/tests/integration_tests/result_set_tests.py index bd44661d8d4f0..626468fc5aae0 100644 --- a/tests/integration_tests/result_set_tests.py +++ b/tests/integration_tests/result_set_tests.py @@ -48,9 +48,9 @@ def test_get_columns_basic(self): self.assertEqual( results.columns, [ - {"is_date": False, "type": "STRING", "name": "a"}, - {"is_date": False, "type": "STRING", "name": "b"}, - {"is_date": False, "type": "STRING", "name": "c"}, + {"is_dttm": False, "type": "STRING", "name": "a"}, + {"is_dttm": False, "type": "STRING", "name": "b"}, + {"is_dttm": False, "type": "STRING", "name": "c"}, ], ) @@ -61,8 +61,8 @@ def test_get_columns_with_int(self): self.assertEqual( results.columns, [ - {"is_date": False, "type": "STRING", "name": "a"}, - {"is_date": False, "type": "INT", "name": "b"}, + {"is_dttm": False, "type": "STRING", "name": "a"}, + {"is_dttm": False, "type": "INT", "name": "b"}, ], ) @@ -76,11 +76,11 @@ def test_get_columns_type_inference(self): self.assertEqual( results.columns, [ - {"is_date": False, "type": "FLOAT", "name": "a"}, - {"is_date": False, "type": "INT", "name": "b"}, - {"is_date": False, "type": "STRING", "name": "c"}, - {"is_date": True, "type": "DATETIME", "name": "d"}, - {"is_date": False, "type": "BOOL", "name": "e"}, + {"is_dttm": False, "type": "FLOAT", "name": "a"}, + {"is_dttm": False, "type": "INT", "name": "b"}, + {"is_dttm": False, "type": "STRING", "name": "c"}, + {"is_dttm": True, "type": "DATETIME", "name": "d"}, + {"is_dttm": False, "type": "BOOL", "name": "e"}, ], ) diff --git a/tests/integration_tests/security/api_tests.py b/tests/integration_tests/security/api_tests.py index f936219971517..9a5a085c81c34 100644 --- a/tests/integration_tests/security/api_tests.py +++ b/tests/integration_tests/security/api_tests.py @@ -19,10 +19,18 @@ import json import jwt +import pytest -from tests.integration_tests.base_tests import SupersetTestCase from flask_wtf.csrf import generate_csrf +from superset import db +from superset.embedded.dao import EmbeddedDAO +from superset.models.dashboard import Dashboard from superset.utils.urls import get_url_host +from tests.integration_tests.base_tests import SupersetTestCase +from tests.integration_tests.fixtures.birth_names_dashboard import ( + load_birth_names_dashboard_with_slices, + load_birth_names_data, +) class TestSecurityCsrfApi(SupersetTestCase): @@ -78,10 +86,13 @@ def test_post_guest_token_unauthorized(self): response = self.client.post(self.uri) self.assert403(response) + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") def test_post_guest_token_authorized(self): + self.dash = db.session.query(Dashboard).filter_by(slug="births").first() + self.embedded = EmbeddedDAO.upsert(self.dash, []) self.login(username="admin") user = {"username": "bob", "first_name": "Bob", "last_name": "Also Bob"} - resource = {"type": "dashboard", "id": "blah"} + resource = {"type": "dashboard", "id": str(self.embedded.uuid)} rls_rule = {"dataset": 1, "clause": "1=1"} params = {"user": user, "resources": [resource], "rls": [rls_rule]} @@ -99,3 +110,17 @@ def test_post_guest_token_authorized(self): ) self.assertEqual(user, decoded_token["user"]) self.assertEqual(resource, decoded_token["resources"][0]) + + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") + def test_post_guest_token_bad_resources(self): + self.login(username="admin") + user = {"username": "bob", "first_name": "Bob", "last_name": "Also Bob"} + resource = {"type": "dashboard", "id": "bad-id"} + rls_rule = {"dataset": 1, "clause": "1=1"} + params = {"user": user, "resources": [resource], "rls": [rls_rule]} + + response = self.client.post( + self.uri, data=json.dumps(params), content_type="application/json" + ) + + self.assert400(response) diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index 0b3a8b1d82d88..82b4d8717d14d 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -595,15 +595,16 @@ def test_public_sync_role_builtin_perms(self): for pvm in current_app.config["FAB_ROLES"]["TestRole"]: assert pvm in public_role_resource_names + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") def test_sqllab_gamma_user_schema_access_to_sqllab(self): session = db.session - example_db = session.query(Database).filter_by(database_name="examples").one() example_db.expose_in_sqllab = True session.commit() arguments = { "keys": ["none"], + "columns": ["expose_in_sqllab"], "filters": [{"col": "expose_in_sqllab", "opr": "eq", "value": True}], "order_columns": "database_name", "order_direction": "asc", diff --git a/tests/integration_tests/sqla_models_tests.py b/tests/integration_tests/sqla_models_tests.py index bbe062e509ba9..d23b95f53cd3d 100644 --- a/tests/integration_tests/sqla_models_tests.py +++ b/tests/integration_tests/sqla_models_tests.py @@ -455,7 +455,8 @@ def test_fetch_metadata_for_updated_virtual_table(self): # make sure the columns have been mapped properly assert len(table.columns) == 4 - table.fetch_metadata() + table.fetch_metadata(commit=False) + # assert that the removed column has been dropped and # the physical and calculated columns are present assert {col.column_name for col in table.columns} == { @@ -473,6 +474,8 @@ def test_fetch_metadata_for_updated_virtual_table(self): assert VIRTUAL_TABLE_STRING_TYPES[backend].match(cols["mycase"].type) assert cols["expr"].expression == "case when 1 then 1 else 0 end" + db.session.delete(table) + @patch("superset.models.core.Database.db_engine_spec", BigQueryEngineSpec) def test_labels_expected_on_mutated_query(self): query_obj = { diff --git a/tests/integration_tests/sqllab_tests.py b/tests/integration_tests/sqllab_tests.py index 5a98ddebfa945..49c6a771e5ad3 100644 --- a/tests/integration_tests/sqllab_tests.py +++ b/tests/integration_tests/sqllab_tests.py @@ -477,8 +477,8 @@ def test_sqllab_viz(self): "datasourceName": f"test_viz_flow_table_{random()}", "schema": "superset", "columns": [ - {"is_date": False, "type": "STRING", "name": f"viz_type_{random()}"}, - {"is_date": False, "type": "OBJECT", "name": f"ccount_{random()}"}, + {"is_dttm": False, "type": "STRING", "name": f"viz_type_{random()}"}, + {"is_dttm": False, "type": "OBJECT", "name": f"ccount_{random()}"}, ], "sql": """\ SELECT * @@ -507,8 +507,8 @@ def test_sqllab_viz_bad_payload(self): "chartType": "dist_bar", "schema": "superset", "columns": [ - {"is_date": False, "type": "STRING", "name": f"viz_type_{random()}"}, - {"is_date": False, "type": "OBJECT", "name": f"ccount_{random()}"}, + {"is_dttm": False, "type": "STRING", "name": f"viz_type_{random()}"}, + {"is_dttm": False, "type": "OBJECT", "name": f"ccount_{random()}"}, ], "sql": """\ SELECT * diff --git a/tests/integration_tests/test_jinja_context.py b/tests/integration_tests/test_jinja_context.py new file mode 100644 index 0000000000000..879881a2996ae --- /dev/null +++ b/tests/integration_tests/test_jinja_context.py @@ -0,0 +1,190 @@ +# 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 datetime import datetime +from unittest import mock + +import pytest +from flask.ctx import AppContext +from pytest_mock import MockFixture + +import superset.utils.database +from superset.exceptions import SupersetTemplateException +from superset.jinja_context import get_template_processor + + +def test_process_template(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "SELECT '{{ 1+1 }}'" + tp = get_template_processor(database=maindb) + assert tp.process_template(template) == "SELECT '2'" + + +def test_get_template_kwarg(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ foo }}" + tp = get_template_processor(database=maindb, foo="bar") + assert tp.process_template(template) == "bar" + + +def test_template_kwarg(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ foo }}" + tp = get_template_processor(database=maindb) + assert tp.process_template(template, foo="bar") == "bar" + + +def test_get_template_kwarg_dict(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ foo.bar }}" + tp = get_template_processor(database=maindb, foo={"bar": "baz"}) + assert tp.process_template(template) == "baz" + + +def test_template_kwarg_dict(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ foo.bar }}" + tp = get_template_processor(database=maindb) + assert tp.process_template(template, foo={"bar": "baz"}) == "baz" + + +def test_get_template_kwarg_lambda(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ foo() }}" + tp = get_template_processor(database=maindb, foo=lambda: "bar") + with pytest.raises(SupersetTemplateException): + tp.process_template(template) + + +def test_template_kwarg_lambda(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ foo() }}" + tp = get_template_processor(database=maindb) + with pytest.raises(SupersetTemplateException): + tp.process_template(template, foo=lambda: "bar") + + +def test_get_template_kwarg_module(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ dt(2017, 1, 1).isoformat() }}" + tp = get_template_processor(database=maindb, dt=datetime) + with pytest.raises(SupersetTemplateException): + tp.process_template(template) + + +def test_template_kwarg_module(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ dt(2017, 1, 1).isoformat() }}" + tp = get_template_processor(database=maindb) + with pytest.raises(SupersetTemplateException): + tp.process_template(template, dt=datetime) + + +def test_get_template_kwarg_nested_module(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ foo.dt }}" + tp = get_template_processor(database=maindb, foo={"dt": datetime}) + with pytest.raises(SupersetTemplateException): + tp.process_template(template) + + +def test_template_kwarg_nested_module(app_context: AppContext) -> None: + maindb = superset.utils.database.get_example_database() + template = "{{ foo.dt }}" + tp = get_template_processor(database=maindb) + with pytest.raises(SupersetTemplateException): + tp.process_template(template, foo={"bar": datetime}) + + +def test_template_hive(app_context: AppContext, mocker: MockFixture) -> None: + lp_mock = mocker.patch( + "superset.jinja_context.HiveTemplateProcessor.latest_partition" + ) + lp_mock.return_value = "the_latest" + db = mock.Mock() + db.backend = "hive" + template = "{{ hive.latest_partition('my_table') }}" + tp = get_template_processor(database=db) + assert tp.process_template(template) == "the_latest" + + +def test_template_context_addons(app_context: AppContext, mocker: MockFixture) -> None: + addons_mock = mocker.patch("superset.jinja_context.context_addons") + addons_mock.return_value = {"datetime": datetime} + maindb = superset.utils.database.get_example_database() + template = "SELECT '{{ datetime(2017, 1, 1).isoformat() }}'" + tp = get_template_processor(database=maindb) + assert tp.process_template(template) == "SELECT '2017-01-01T00:00:00'" + + +def test_custom_process_template(app_context: AppContext, mocker: MockFixture) -> None: + """Test macro defined in custom template processor works.""" + + mock_dt = mocker.patch( + "tests.integration_tests.superset_test_custom_template_processors.datetime" + ) + mock_dt.utcnow = mock.Mock(return_value=datetime(1970, 1, 1)) + db = mock.Mock() + db.backend = "db_for_macros_testing" + tp = get_template_processor(database=db) + + template = "SELECT '$DATE()'" + assert tp.process_template(template) == f"SELECT '1970-01-01'" + + template = "SELECT '$DATE(1, 2)'" + assert tp.process_template(template) == "SELECT '1970-01-02'" + + +def test_custom_get_template_kwarg(app_context: AppContext) -> None: + """Test macro passed as kwargs when getting template processor + works in custom template processor.""" + db = mock.Mock() + db.backend = "db_for_macros_testing" + template = "$foo()" + tp = get_template_processor(database=db, foo=lambda: "bar") + assert tp.process_template(template) == "bar" + + +def test_custom_template_kwarg(app_context: AppContext) -> None: + """Test macro passed as kwargs when processing template + works in custom template processor.""" + db = mock.Mock() + db.backend = "db_for_macros_testing" + template = "$foo()" + tp = get_template_processor(database=db) + assert tp.process_template(template, foo=lambda: "bar") == "bar" + + +def test_custom_template_processors_overwrite(app_context: AppContext) -> None: + """Test template processor for presto gets overwritten by custom one.""" + db = mock.Mock() + db.backend = "db_for_macros_testing" + tp = get_template_processor(database=db) + + template = "SELECT '{{ datetime(2017, 1, 1).isoformat() }}'" + assert tp.process_template(template) == template + + template = "SELECT '{{ DATE(1, 2) }}'" + assert tp.process_template(template) == template + + +def test_custom_template_processors_ignored(app_context: AppContext) -> None: + """Test custom template processor is ignored for a difference backend + database.""" + maindb = superset.utils.database.get_example_database() + template = "SELECT '$DATE()'" + tp = get_template_processor(database=maindb) + assert tp.process_template(template) == template diff --git a/tests/integration_tests/utils_tests.py b/tests/integration_tests/utils_tests.py index 765f586ced6c5..7e8aede6a39c7 100644 --- a/tests/integration_tests/utils_tests.py +++ b/tests/integration_tests/utils_tests.py @@ -15,7 +15,6 @@ # specific language governing permissions and limitations # under the License. # isort:skip_file -import unittest import uuid from datetime import date, datetime, time, timedelta from decimal import Decimal @@ -24,6 +23,8 @@ import re from typing import Any, Tuple, List, Optional from unittest.mock import Mock, patch + +from superset.databases.commands.exceptions import DatabaseInvalidError from tests.integration_tests.fixtures.birth_names_dashboard import ( load_birth_names_dashboard_with_slices, load_birth_names_data, @@ -736,7 +737,7 @@ def test_get_or_create_db(self): db.session.commit() def test_get_or_create_db_invalid_uri(self): - with self.assertRaises(ArgumentError): + with self.assertRaises(DatabaseInvalidError): get_or_create_db("test_db", "yoursql:superset.db/()") def test_get_iterable(self): diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 4987aaf0e0e5c..86fb0127b84f3 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -17,7 +17,7 @@ # pylint: disable=redefined-outer-name, import-outside-toplevel import importlib -from typing import Any, Iterator +from typing import Any, Callable, Iterator import pytest from pytest_mock import MockFixture @@ -31,25 +31,33 @@ @pytest.fixture -def session(mocker: MockFixture) -> Iterator[Session]: +def get_session(mocker: MockFixture) -> Callable[[], Session]: """ Create an in-memory SQLite session to test models. """ engine = create_engine("sqlite://") - Session_ = sessionmaker(bind=engine) # pylint: disable=invalid-name - in_memory_session = Session_() - # flask calls session.remove() - in_memory_session.remove = lambda: None + def get_session(): + Session_ = sessionmaker(bind=engine) # pylint: disable=invalid-name + in_memory_session = Session_() - # patch session - mocker.patch( - "superset.security.SupersetSecurityManager.get_session", - return_value=in_memory_session, - ) - mocker.patch("superset.db.session", in_memory_session) + # flask calls session.remove() + in_memory_session.remove = lambda: None - yield in_memory_session + # patch session + mocker.patch( + "superset.security.SupersetSecurityManager.get_session", + return_value=in_memory_session, + ) + mocker.patch("superset.db.session", in_memory_session) + return in_memory_session + + return get_session + + +@pytest.fixture +def session(get_session) -> Iterator[Session]: + yield get_session() @pytest.fixture(scope="module") diff --git a/tests/unit_tests/databases/utils_test.py b/tests/unit_tests/databases/utils_test.py new file mode 100644 index 0000000000000..8dbc11a3b7a70 --- /dev/null +++ b/tests/unit_tests/databases/utils_test.py @@ -0,0 +1,40 @@ +# 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 sqlalchemy.engine.url import make_url +from sqlalchemy.orm.session import Session + +from superset.databases.utils import make_url_safe + + +def test_make_url_safe_string(app_context: None, session: Session) -> None: + """ + Test converting a string to a safe uri + """ + uri_string = "postgresql+psycopg2://superset:***@127.0.0.1:5432/superset" + uri_safe = make_url_safe(uri_string) + assert str(uri_safe) == uri_string + assert uri_safe == make_url(uri_string) + + +def test_make_url_safe_url(app_context: None, session: Session) -> None: + """ + Test converting a url to a safe uri + """ + uri = make_url("postgresql+psycopg2://superset:***@127.0.0.1:5432/superset") + uri_safe = make_url_safe(uri) + assert uri_safe == uri diff --git a/tests/unit_tests/datasets/conftest.py b/tests/unit_tests/datasets/conftest.py new file mode 100644 index 0000000000000..9d9403934d0e1 --- /dev/null +++ b/tests/unit_tests/datasets/conftest.py @@ -0,0 +1,118 @@ +# 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 typing import Any, Dict, TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from superset.connectors.sqla.models import SqlMetric, TableColumn + + +@pytest.fixture +def columns_default() -> Dict[str, Any]: + """Default props for new columns""" + return { + "changed_by": 1, + "created_by": 1, + "datasets": [], + "tables": [], + "is_additive": False, + "is_aggregation": False, + "is_dimensional": False, + "is_filterable": True, + "is_increase_desired": True, + "is_partition": False, + "is_physical": True, + "is_spatial": False, + "is_temporal": False, + "description": None, + "extra_json": "{}", + "unit": None, + "warning_text": None, + "is_managed_externally": False, + "external_url": None, + } + + +@pytest.fixture +def sample_columns() -> Dict["TableColumn", Dict[str, Any]]: + from superset.connectors.sqla.models import TableColumn + + return { + TableColumn(column_name="ds", is_dttm=1, type="TIMESTAMP"): { + "name": "ds", + "expression": "ds", + "type": "TIMESTAMP", + "is_temporal": True, + "is_physical": True, + }, + TableColumn(column_name="num_boys", type="INTEGER", groupby=True): { + "name": "num_boys", + "expression": "num_boys", + "type": "INTEGER", + "is_dimensional": True, + "is_physical": True, + }, + TableColumn(column_name="region", type="VARCHAR", groupby=True): { + "name": "region", + "expression": "region", + "type": "VARCHAR", + "is_dimensional": True, + "is_physical": True, + }, + TableColumn( + column_name="profit", + type="INTEGER", + groupby=False, + expression="revenue-expenses", + ): { + "name": "profit", + "expression": "revenue-expenses", + "type": "INTEGER", + "is_physical": False, + }, + } + + +@pytest.fixture +def sample_metrics() -> Dict["SqlMetric", Dict[str, Any]]: + from superset.connectors.sqla.models import SqlMetric + + return { + SqlMetric(metric_name="cnt", expression="COUNT(*)", metric_type="COUNT"): { + "name": "cnt", + "expression": "COUNT(*)", + "extra_json": '{"metric_type": "COUNT"}', + "type": "UNKNOWN", + "is_additive": True, + "is_aggregation": True, + "is_filterable": False, + "is_physical": False, + }, + SqlMetric( + metric_name="avg revenue", expression="AVG(revenue)", metric_type="AVG" + ): { + "name": "avg revenue", + "expression": "AVG(revenue)", + "extra_json": '{"metric_type": "AVG"}', + "type": "UNKNOWN", + "is_additive": False, + "is_aggregation": True, + "is_filterable": False, + "is_physical": False, + }, + } diff --git a/tests/unit_tests/datasets/test_models.py b/tests/unit_tests/datasets/test_models.py index d21ef8ea60a94..08e0f11e0d354 100644 --- a/tests/unit_tests/datasets/test_models.py +++ b/tests/unit_tests/datasets/test_models.py @@ -15,14 +15,17 @@ # specific language governing permissions and limitations # under the License. -# pylint: disable=import-outside-toplevel, unused-argument, unused-import, too-many-locals, invalid-name, too-many-lines - import json -from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, TYPE_CHECKING from pytest_mock import MockFixture from sqlalchemy.orm.session import Session +from tests.unit_tests.utils.db import get_test_user + +if TYPE_CHECKING: + from superset.connectors.sqla.models import SqlMetric, TableColumn + def test_dataset_model(app_context: None, session: Session) -> None: """ @@ -50,6 +53,7 @@ def test_dataset_model(app_context: None, session: Session) -> None: session.flush() dataset = Dataset( + database=table.database, name="positions", expression=""" SELECT array_agg(array[longitude,latitude]) AS position @@ -148,6 +152,7 @@ def test_cascade_delete_dataset(app_context: None, session: Session) -> None: SELECT array_agg(array[longitude,latitude]) AS position FROM my_catalog.my_schema.my_table """, + database=table.database, tables=[table], columns=[ Column( @@ -185,7 +190,7 @@ def test_dataset_attributes(app_context: None, session: Session) -> None: columns = [ TableColumn(column_name="ds", is_dttm=1, type="TIMESTAMP"), - TableColumn(column_name="user_id", type="INTEGER"), + TableColumn(column_name="num_boys", type="INTEGER"), TableColumn(column_name="revenue", type="INTEGER"), TableColumn(column_name="expenses", type="INTEGER"), TableColumn( @@ -254,6 +259,7 @@ def test_dataset_attributes(app_context: None, session: Session) -> None: "main_dttm_col", "metrics", "offset", + "owners", "params", "perm", "schema", @@ -265,7 +271,13 @@ def test_dataset_attributes(app_context: None, session: Session) -> None: ] -def test_create_physical_sqlatable(app_context: None, session: Session) -> None: +def test_create_physical_sqlatable( + app_context: None, + session: Session, + sample_columns: Dict["TableColumn", Dict[str, Any]], + sample_metrics: Dict["SqlMetric", Dict[str, Any]], + columns_default: Dict[str, Any], +) -> None: """ Test shadow write when creating a new ``SqlaTable``. @@ -274,7 +286,7 @@ def test_create_physical_sqlatable(app_context: None, session: Session) -> None: """ from superset.columns.models import Column from superset.columns.schemas import ColumnSchema - from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn + from superset.connectors.sqla.models import SqlaTable from superset.datasets.models import Dataset from superset.datasets.schemas import DatasetSchema from superset.models.core import Database @@ -283,19 +295,11 @@ def test_create_physical_sqlatable(app_context: None, session: Session) -> None: engine = session.get_bind() Dataset.metadata.create_all(engine) # pylint: disable=no-member - - columns = [ - TableColumn(column_name="ds", is_dttm=1, type="TIMESTAMP"), - TableColumn(column_name="user_id", type="INTEGER"), - TableColumn(column_name="revenue", type="INTEGER"), - TableColumn(column_name="expenses", type="INTEGER"), - TableColumn( - column_name="profit", type="INTEGER", expression="revenue-expenses" - ), - ] - metrics = [ - SqlMetric(metric_name="cnt", expression="COUNT(*)"), - ] + user1 = get_test_user(1, "abc") + columns = list(sample_columns.keys()) + metrics = list(sample_metrics.keys()) + expected_table_columns = list(sample_columns.values()) + expected_metric_columns = list(sample_metrics.values()) sqla_table = SqlaTable( table_name="old_dataset", @@ -317,6 +321,9 @@ def test_create_physical_sqlatable(app_context: None, session: Session) -> None: "import_time": 1606677834, } ), + created_by=user1, + changed_by=user1, + owners=[user1], perm=None, filter_select_enabled=1, fetch_values_predicate="foo IN (1, 2)", @@ -329,164 +336,85 @@ def test_create_physical_sqlatable(app_context: None, session: Session) -> None: session.flush() # ignore these keys when comparing results - ignored_keys = {"created_on", "changed_on", "uuid"} + ignored_keys = {"created_on", "changed_on"} # check that columns were created column_schema = ColumnSchema() - column_schemas = [ + actual_columns = [ {k: v for k, v in column_schema.dump(column).items() if k not in ignored_keys} for column in session.query(Column).all() ] - assert column_schemas == [ - { - "changed_by": None, - "created_by": None, - "description": None, - "expression": "ds", - "extra_json": "{}", - "id": 1, - "is_increase_desired": True, - "is_additive": False, - "is_aggregation": False, - "is_partition": False, - "is_physical": True, - "is_spatial": False, - "is_temporal": True, - "name": "ds", - "type": "TIMESTAMP", - "unit": None, - "warning_text": None, - "is_managed_externally": False, - "external_url": None, - }, - { - "changed_by": None, - "created_by": None, - "description": None, - "expression": "user_id", - "extra_json": "{}", - "id": 2, - "is_increase_desired": True, - "is_additive": False, - "is_aggregation": False, - "is_partition": False, - "is_physical": True, - "is_spatial": False, - "is_temporal": False, - "name": "user_id", - "type": "INTEGER", - "unit": None, - "warning_text": None, - "is_managed_externally": False, - "external_url": None, - }, - { - "changed_by": None, - "created_by": None, - "description": None, - "expression": "revenue", - "extra_json": "{}", - "id": 3, - "is_increase_desired": True, - "is_additive": False, - "is_aggregation": False, - "is_partition": False, - "is_physical": True, - "is_spatial": False, - "is_temporal": False, - "name": "revenue", - "type": "INTEGER", - "unit": None, - "warning_text": None, - "is_managed_externally": False, - "external_url": None, - }, - { - "changed_by": None, - "created_by": None, - "description": None, - "expression": "expenses", - "extra_json": "{}", - "id": 4, - "is_increase_desired": True, - "is_additive": False, - "is_aggregation": False, - "is_partition": False, + num_physical_columns = len( + [col for col in expected_table_columns if col.get("is_physical") == True] + ) + num_dataset_table_columns = len(columns) + num_dataset_metric_columns = len(metrics) + assert ( + len(actual_columns) + == num_physical_columns + num_dataset_table_columns + num_dataset_metric_columns + ) + + # table columns are created before dataset columns are created + offset = 0 + for i in range(num_physical_columns): + assert actual_columns[i + offset] == { + **columns_default, + **expected_table_columns[i], + "id": i + offset + 1, + # physical columns for table have its own uuid + "uuid": actual_columns[i + offset]["uuid"], "is_physical": True, - "is_spatial": False, - "is_temporal": False, - "name": "expenses", - "type": "INTEGER", - "unit": None, - "warning_text": None, - "is_managed_externally": False, - "external_url": None, - }, - { - "changed_by": None, - "created_by": None, - "description": None, - "expression": "revenue-expenses", - "extra_json": "{}", - "id": 5, - "is_increase_desired": True, - "is_additive": False, - "is_aggregation": False, - "is_partition": False, - "is_physical": False, - "is_spatial": False, - "is_temporal": False, - "name": "profit", - "type": "INTEGER", - "unit": None, - "warning_text": None, - "is_managed_externally": False, - "external_url": None, - }, - { - "changed_by": None, + # table columns do not have creators "created_by": None, - "description": None, - "expression": "COUNT(*)", - "extra_json": "{}", - "id": 6, - "is_increase_desired": True, - "is_additive": False, - "is_aggregation": True, - "is_partition": False, - "is_physical": False, - "is_spatial": False, - "is_temporal": False, - "name": "cnt", - "type": "Unknown", - "unit": None, - "warning_text": None, - "is_managed_externally": False, - "external_url": None, - }, - ] + "tables": [1], + } + + offset += num_physical_columns + for i, column in enumerate(sqla_table.columns): + assert actual_columns[i + offset] == { + **columns_default, + **expected_table_columns[i], + "id": i + offset + 1, + # columns for dataset reuses the same uuid of TableColumn + "uuid": str(column.uuid), + "datasets": [1], + } + + offset += num_dataset_table_columns + for i, metric in enumerate(sqla_table.metrics): + assert actual_columns[i + offset] == { + **columns_default, + **expected_metric_columns[i], + "id": i + offset + 1, + "uuid": str(metric.uuid), + "datasets": [1], + } # check that table was created table_schema = TableSchema() tables = [ - {k: v for k, v in table_schema.dump(table).items() if k not in ignored_keys} - for table in session.query(Table).all() - ] - assert tables == [ { - "extra_json": "{}", - "catalog": None, - "schema": "my_schema", - "name": "old_dataset", - "id": 1, - "database": 1, - "columns": [1, 2, 3, 4], - "created_by": None, - "changed_by": None, - "is_managed_externally": False, - "external_url": None, + k: v + for k, v in table_schema.dump(table).items() + if k not in (ignored_keys | {"uuid"}) } + for table in session.query(Table).all() ] + assert len(tables) == 1 + assert tables[0] == { + "id": 1, + "database": 1, + "created_by": 1, + "changed_by": 1, + "datasets": [1], + "columns": [1, 2, 3], + "extra_json": "{}", + "catalog": None, + "schema": "my_schema", + "name": "old_dataset", + "is_managed_externally": False, + "external_url": None, + } # check that dataset was created dataset_schema = DatasetSchema() @@ -494,26 +422,32 @@ def test_create_physical_sqlatable(app_context: None, session: Session) -> None: {k: v for k, v in dataset_schema.dump(dataset).items() if k not in ignored_keys} for dataset in session.query(Dataset).all() ] - assert datasets == [ - { - "id": 1, - "sqlatable_id": 1, - "name": "old_dataset", - "changed_by": None, - "created_by": None, - "columns": [1, 2, 3, 4, 5, 6], - "is_physical": True, - "tables": [1], - "extra_json": "{}", - "expression": "old_dataset", - "is_managed_externally": False, - "external_url": None, - } - ] + assert len(datasets) == 1 + assert datasets[0] == { + "id": 1, + "uuid": str(sqla_table.uuid), + "created_by": 1, + "changed_by": 1, + "owners": [1], + "name": "old_dataset", + "columns": [4, 5, 6, 7, 8, 9], + "is_physical": True, + "database": 1, + "tables": [1], + "extra_json": "{}", + "expression": "old_dataset", + "is_managed_externally": False, + "external_url": None, + } def test_create_virtual_sqlatable( - mocker: MockFixture, app_context: None, session: Session + app_context: None, + mocker: MockFixture, + session: Session, + sample_columns: Dict["TableColumn", Dict[str, Any]], + sample_metrics: Dict["SqlMetric", Dict[str, Any]], + columns_default: Dict[str, Any], ) -> None: """ Test shadow write when creating a new ``SqlaTable``. @@ -528,7 +462,7 @@ def test_create_virtual_sqlatable( from superset.columns.models import Column from superset.columns.schemas import ColumnSchema - from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn + from superset.connectors.sqla.models import SqlaTable from superset.datasets.models import Dataset from superset.datasets.schemas import DatasetSchema from superset.models.core import Database @@ -536,8 +470,20 @@ def test_create_virtual_sqlatable( engine = session.get_bind() Dataset.metadata.create_all(engine) # pylint: disable=no-member - - # create the ``Table`` that the virtual dataset points to + user1 = get_test_user(1, "abc") + physical_table_columns: List[Dict[str, Any]] = [ + dict( + name="ds", + is_temporal=True, + type="TIMESTAMP", + expression="ds", + is_physical=True, + ), + dict(name="num_boys", type="INTEGER", expression="num_boys", is_physical=True), + dict(name="revenue", type="INTEGER", expression="revenue", is_physical=True), + dict(name="expenses", type="INTEGER", expression="expenses", is_physical=True), + ] + # create a physical ``Table`` that the virtual dataset points to database = Database(database_name="my_database", sqlalchemy_uri="sqlite://") table = Table( name="some_table", @@ -545,30 +491,26 @@ def test_create_virtual_sqlatable( catalog=None, database=database, columns=[ - Column(name="ds", is_temporal=True, type="TIMESTAMP"), - Column(name="user_id", type="INTEGER"), - Column(name="revenue", type="INTEGER"), - Column(name="expenses", type="INTEGER"), + Column(**props, created_by=user1, changed_by=user1) + for props in physical_table_columns ], ) session.add(table) session.commit() + assert session.query(Table).count() == 1 + assert session.query(Dataset).count() == 0 + # create virtual dataset - columns = [ - TableColumn(column_name="ds", is_dttm=1, type="TIMESTAMP"), - TableColumn(column_name="user_id", type="INTEGER"), - TableColumn(column_name="revenue", type="INTEGER"), - TableColumn(column_name="expenses", type="INTEGER"), - TableColumn( - column_name="profit", type="INTEGER", expression="revenue-expenses" - ), - ] - metrics = [ - SqlMetric(metric_name="cnt", expression="COUNT(*)"), - ] + columns = list(sample_columns.keys()) + metrics = list(sample_metrics.keys()) + expected_table_columns = list(sample_columns.values()) + expected_metric_columns = list(sample_metrics.values()) sqla_table = SqlaTable( + created_by=user1, + changed_by=user1, + owners=[user1], table_name="old_dataset", columns=columns, metrics=metrics, @@ -583,7 +525,7 @@ def test_create_virtual_sqlatable( sql=""" SELECT ds, - user_id, + num_boys, revenue, expenses, revenue - expenses AS profit @@ -607,227 +549,54 @@ def test_create_virtual_sqlatable( session.add(sqla_table) session.flush() - # ignore these keys when comparing results - ignored_keys = {"created_on", "changed_on", "uuid"} + # should not add a new table + assert session.query(Table).count() == 1 + assert session.query(Dataset).count() == 1 - # check that columns were created + # ignore these keys when comparing results + ignored_keys = {"created_on", "changed_on"} column_schema = ColumnSchema() - column_schemas = [ + actual_columns = [ {k: v for k, v in column_schema.dump(column).items() if k not in ignored_keys} for column in session.query(Column).all() ] - assert column_schemas == [ - { - "type": "TIMESTAMP", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": None, - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "ds", - "is_physical": True, - "changed_by": None, - "is_temporal": True, - "id": 1, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "INTEGER", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": None, - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "user_id", - "is_physical": True, - "changed_by": None, - "is_temporal": False, - "id": 2, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "INTEGER", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": None, - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "revenue", - "is_physical": True, - "changed_by": None, - "is_temporal": False, - "id": 3, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "INTEGER", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": None, - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "expenses", - "is_physical": True, - "changed_by": None, - "is_temporal": False, - "id": 4, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "TIMESTAMP", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": "ds", - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "ds", - "is_physical": False, - "changed_by": None, - "is_temporal": True, - "id": 5, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "INTEGER", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": "user_id", - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "user_id", - "is_physical": False, - "changed_by": None, - "is_temporal": False, - "id": 6, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "INTEGER", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": "revenue", - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "revenue", - "is_physical": False, - "changed_by": None, - "is_temporal": False, - "id": 7, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "INTEGER", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": "expenses", - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "expenses", - "is_physical": False, - "changed_by": None, - "is_temporal": False, - "id": 8, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "INTEGER", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": "revenue-expenses", - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "profit", - "is_physical": False, - "changed_by": None, - "is_temporal": False, - "id": 9, - "is_aggregation": False, - "external_url": None, - "is_managed_externally": False, - }, - { - "type": "Unknown", - "is_additive": False, - "extra_json": "{}", - "is_partition": False, - "expression": "COUNT(*)", - "unit": None, - "warning_text": None, - "created_by": None, - "is_increase_desired": True, - "description": None, - "is_spatial": False, - "name": "cnt", + num_physical_columns = len(physical_table_columns) + num_dataset_table_columns = len(columns) + num_dataset_metric_columns = len(metrics) + assert ( + len(actual_columns) + == num_physical_columns + num_dataset_table_columns + num_dataset_metric_columns + ) + + for i, column in enumerate(table.columns): + assert actual_columns[i] == { + **columns_default, + **physical_table_columns[i], + "id": i + 1, + "uuid": str(column.uuid), + "tables": [1], + } + + offset = num_physical_columns + for i, column in enumerate(sqla_table.columns): + assert actual_columns[i + offset] == { + **columns_default, + **expected_table_columns[i], + "id": i + offset + 1, + "uuid": str(column.uuid), "is_physical": False, - "changed_by": None, - "is_temporal": False, - "id": 10, - "is_aggregation": True, - "external_url": None, - "is_managed_externally": False, - }, - ] + "datasets": [1], + } + + offset = num_physical_columns + num_dataset_table_columns + for i, metric in enumerate(sqla_table.metrics): + assert actual_columns[i + offset] == { + **columns_default, + **expected_metric_columns[i], + "id": i + offset + 1, + "uuid": str(metric.uuid), + "datasets": [1], + } # check that dataset was created, and has a reference to the table dataset_schema = DatasetSchema() @@ -835,30 +604,31 @@ def test_create_virtual_sqlatable( {k: v for k, v in dataset_schema.dump(dataset).items() if k not in ignored_keys} for dataset in session.query(Dataset).all() ] - assert datasets == [ - { - "id": 1, - "sqlatable_id": 1, - "name": "old_dataset", - "changed_by": None, - "created_by": None, - "columns": [5, 6, 7, 8, 9, 10], - "is_physical": False, - "tables": [1], - "extra_json": "{}", - "external_url": None, - "is_managed_externally": False, - "expression": """ + assert len(datasets) == 1 + assert datasets[0] == { + "id": 1, + "database": 1, + "uuid": str(sqla_table.uuid), + "name": "old_dataset", + "changed_by": 1, + "created_by": 1, + "owners": [1], + "columns": [5, 6, 7, 8, 9, 10], + "is_physical": False, + "tables": [1], + "extra_json": "{}", + "external_url": None, + "is_managed_externally": False, + "expression": """ SELECT ds, - user_id, + num_boys, revenue, expenses, revenue - expenses AS profit FROM some_table""", - } - ] + } def test_delete_sqlatable(app_context: None, session: Session) -> None: @@ -886,18 +656,21 @@ def test_delete_sqlatable(app_context: None, session: Session) -> None: session.add(sqla_table) session.flush() - datasets = session.query(Dataset).all() - assert len(datasets) == 1 + assert session.query(Dataset).count() == 1 + assert session.query(Table).count() == 1 + assert session.query(Column).count() == 2 session.delete(sqla_table) session.flush() - # test that dataset was also deleted - datasets = session.query(Dataset).all() - assert len(datasets) == 0 + # test that dataset and dataset columns are also deleted + # but the physical table and table columns are kept + assert session.query(Dataset).count() == 0 + assert session.query(Table).count() == 1 + assert session.query(Column).count() == 1 -def test_update_sqlatable( +def test_update_physical_sqlatable_columns( mocker: MockFixture, app_context: None, session: Session ) -> None: """ @@ -929,21 +702,33 @@ def test_update_sqlatable( session.add(sqla_table) session.flush() + assert session.query(Table).count() == 1 + assert session.query(Dataset).count() == 1 + assert session.query(Column).count() == 2 # 1 for table, 1 for dataset + dataset = session.query(Dataset).one() assert len(dataset.columns) == 1 # add a column to the original ``SqlaTable`` instance - sqla_table.columns.append(TableColumn(column_name="user_id", type="INTEGER")) + sqla_table.columns.append(TableColumn(column_name="num_boys", type="INTEGER")) session.flush() - # check that the column was added to the dataset + assert session.query(Column).count() == 3 dataset = session.query(Dataset).one() assert len(dataset.columns) == 2 + for table_column, dataset_column in zip(sqla_table.columns, dataset.columns): + assert table_column.uuid == dataset_column.uuid # delete the column in the original instance sqla_table.columns = sqla_table.columns[1:] session.flush() + # check that the column was added to the dataset and the added columns have + # the correct uuid. + assert session.query(TableColumn).count() == 1 + # the extra Dataset.column is deleted, but Table.column is kept + assert session.query(Column).count() == 2 + # check that the column was also removed from the dataset dataset = session.query(Dataset).one() assert len(dataset.columns) == 1 @@ -957,7 +742,7 @@ def test_update_sqlatable( assert dataset.columns[0].is_temporal is True -def test_update_sqlatable_schema( +def test_update_physical_sqlatable_schema( mocker: MockFixture, app_context: None, session: Session ) -> None: """ @@ -1003,8 +788,11 @@ def test_update_sqlatable_schema( assert new_dataset.tables[0].id == 2 -def test_update_sqlatable_metric( - mocker: MockFixture, app_context: None, session: Session +def test_update_physical_sqlatable_metrics( + mocker: MockFixture, + app_context: None, + session: Session, + get_session: Callable[[], Session], ) -> None: """ Test that updating a ``SqlaTable`` also updates the corresponding ``Dataset``. @@ -1042,6 +830,9 @@ def test_update_sqlatable_metric( session.flush() # check that the metric was created + # 1 physical column for table + (1 column + 1 metric for datasets) + assert session.query(Column).count() == 3 + column = session.query(Column).filter_by(is_physical=False).one() assert column.expression == "COUNT(*)" @@ -1051,111 +842,35 @@ def test_update_sqlatable_metric( assert column.expression == "MAX(ds)" - -def test_update_virtual_sqlatable_references( - mocker: MockFixture, app_context: None, session: Session -) -> None: - """ - Test that changing the SQL of a virtual ``SqlaTable`` updates ``Dataset``. - - When the SQL is modified the list of referenced tables should be updated in the new - ``Dataset`` model. - """ - # patch session - mocker.patch( - "superset.security.SupersetSecurityManager.get_session", return_value=session - ) - - from superset.columns.models import Column - from superset.connectors.sqla.models import SqlaTable, TableColumn - from superset.datasets.models import Dataset - from superset.models.core import Database - from superset.tables.models import Table - - engine = session.get_bind() - Dataset.metadata.create_all(engine) # pylint: disable=no-member - - database = Database(database_name="my_database", sqlalchemy_uri="sqlite://") - table1 = Table( - name="table_a", - schema="my_schema", - catalog=None, - database=database, - columns=[Column(name="a", type="INTEGER")], - ) - table2 = Table( - name="table_b", - schema="my_schema", - catalog=None, - database=database, - columns=[Column(name="b", type="INTEGER")], - ) - session.add(table1) - session.add(table2) - session.commit() - - # create virtual dataset - columns = [TableColumn(column_name="a", type="INTEGER")] - - sqla_table = SqlaTable( - table_name="old_dataset", - columns=columns, - database=database, - schema="my_schema", - sql="SELECT a FROM table_a", + # in a new session, update new columns and metrics at the same time + # reload the sqla_table so we can test the case that accessing an not already + # loaded attribute (`sqla_table.metrics`) while there are updates on the instance + # may trigger `after_update` before the attribute is loaded + session = get_session() + sqla_table = session.query(SqlaTable).filter(SqlaTable.id == sqla_table.id).one() + sqla_table.columns.append( + TableColumn( + column_name="another_column", + is_dttm=0, + type="TIMESTAMP", + expression="concat('a', 'b')", + ) ) - session.add(sqla_table) - session.flush() - - # check that new dataset has table1 - dataset = session.query(Dataset).one() - assert dataset.tables == [table1] - - # change SQL - sqla_table.sql = "SELECT a, b FROM table_a JOIN table_b" - session.flush() - - # check that new dataset has both tables - new_dataset = session.query(Dataset).one() - assert new_dataset.tables == [table1, table2] - assert new_dataset.expression == "SELECT a, b FROM table_a JOIN table_b" - - -def test_quote_expressions(app_context: None, session: Session) -> None: - """ - Test that expressions are quoted appropriately in columns and datasets. - """ - from superset.columns.models import Column - from superset.connectors.sqla.models import SqlaTable, TableColumn - from superset.datasets.models import Dataset - from superset.models.core import Database - from superset.tables.models import Table - - engine = session.get_bind() - Dataset.metadata.create_all(engine) # pylint: disable=no-member - - columns = [ - TableColumn(column_name="has space", type="INTEGER"), - TableColumn(column_name="no_need", type="INTEGER"), - ] - - sqla_table = SqlaTable( - table_name="old dataset", - columns=columns, - metrics=[], - database=Database(database_name="my_database", sqlalchemy_uri="sqlite://"), + # Here `SqlaTable.after_update` is triggered + # before `sqla_table.metrics` is loaded + sqla_table.metrics.append( + SqlMetric(metric_name="another_metric", expression="COUNT(*)") ) - session.add(sqla_table) + # `SqlaTable.after_update` will trigger again at flushing session.flush() - - dataset = session.query(Dataset).one() - assert dataset.expression == '"old dataset"' - assert dataset.columns[0].expression == '"has space"' - assert dataset.columns[1].expression == "no_need" + assert session.query(Column).count() == 5 -def test_update_physical_sqlatable( - mocker: MockFixture, app_context: None, session: Session +def test_update_physical_sqlatable_database( + mocker: MockFixture, + app_context: None, + session: Session, + get_session: Callable[[], Session], ) -> None: """ Test updating the table on a physical dataset. @@ -1172,9 +887,9 @@ def test_update_physical_sqlatable( from superset.columns.models import Column from superset.connectors.sqla.models import SqlaTable, TableColumn - from superset.datasets.models import Dataset + from superset.datasets.models import Dataset, dataset_column_association_table from superset.models.core import Database - from superset.tables.models import Table + from superset.tables.models import Table, table_column_association_table from superset.tables.schemas import TableSchema engine = session.get_bind() @@ -1184,19 +899,26 @@ def test_update_physical_sqlatable( TableColumn(column_name="a", type="INTEGER"), ] + original_database = Database( + database_name="my_database", sqlalchemy_uri="sqlite://" + ) sqla_table = SqlaTable( - table_name="old_dataset", + table_name="original_table", columns=columns, metrics=[], - database=Database(database_name="my_database", sqlalchemy_uri="sqlite://"), + database=original_database, ) session.add(sqla_table) session.flush() + assert session.query(Table).count() == 1 + assert session.query(Dataset).count() == 1 + assert session.query(Column).count() == 2 # 1 for table, 1 for dataset + # check that the table was created, and that the created dataset points to it table = session.query(Table).one() assert table.id == 1 - assert table.name == "old_dataset" + assert table.name == "original_table" assert table.schema is None assert table.database_id == 1 @@ -1210,122 +932,200 @@ def test_update_physical_sqlatable( session.add(new_database) session.flush() sqla_table.database = new_database + sqla_table.table_name = "new_table" session.flush() + assert session.query(Dataset).count() == 1 + assert session.query(Table).count() == 2 + # is kept for the old table + # is kept for the updated dataset + # is created for the new table + assert session.query(Column).count() == 3 + # ignore these keys when comparing results ignored_keys = {"created_on", "changed_on", "uuid"} # check that the old table still exists, and that the dataset points to the newly - # created table (id=2) and column (id=2), on the new database (also id=2) + # created table, column and dataset table_schema = TableSchema() tables = [ {k: v for k, v in table_schema.dump(table).items() if k not in ignored_keys} for table in session.query(Table).all() ] - assert tables == [ - { - "created_by": None, - "extra_json": "{}", - "name": "old_dataset", - "changed_by": None, - "catalog": None, - "columns": [1], - "database": 1, - "external_url": None, - "schema": None, - "id": 1, - "is_managed_externally": False, - }, - { - "created_by": None, - "extra_json": "{}", - "name": "old_dataset", - "changed_by": None, - "catalog": None, - "columns": [2], - "database": 2, - "external_url": None, - "schema": None, - "id": 2, - "is_managed_externally": False, - }, - ] + assert tables[0] == { + "id": 1, + "database": 1, + "columns": [1], + "datasets": [], + "created_by": None, + "changed_by": None, + "extra_json": "{}", + "catalog": None, + "schema": None, + "name": "original_table", + "external_url": None, + "is_managed_externally": False, + } + assert tables[1] == { + "id": 2, + "database": 2, + "datasets": [1], + "columns": [3], + "created_by": None, + "changed_by": None, + "catalog": None, + "schema": None, + "name": "new_table", + "is_managed_externally": False, + "extra_json": "{}", + "external_url": None, + } # check that dataset now points to the new table assert dataset.tables[0].database_id == 2 + # and a new column is created + assert len(dataset.columns) == 1 + assert dataset.columns[0].id == 2 # point ``SqlaTable`` back - sqla_table.database_id = 1 + sqla_table.database = original_database + sqla_table.table_name = "original_table" session.flush() - # check that dataset points to the original table + # should not create more table and datasets + assert session.query(Dataset).count() == 1 + assert session.query(Table).count() == 2 + # is deleted for the old table + # is kept for the updated dataset + # is kept for the new table + assert session.query(Column.id).order_by(Column.id).all() == [ + (1,), + (2,), + (3,), + ] + assert session.query(dataset_column_association_table).all() == [(1, 2)] + assert session.query(table_column_association_table).all() == [(1, 1), (2, 3)] + assert session.query(Dataset).filter_by(id=1).one().columns[0].id == 2 + assert session.query(Table).filter_by(id=2).one().columns[0].id == 3 + assert session.query(Table).filter_by(id=1).one().columns[0].id == 1 + + # the dataset points back to the original table assert dataset.tables[0].database_id == 1 + assert dataset.tables[0].name == "original_table" + + # kept the original column + assert dataset.columns[0].id == 2 + session.commit() + session.close() + # querying in a new session should still return the same result + session = get_session() + assert session.query(table_column_association_table).all() == [(1, 1), (2, 3)] -def test_update_physical_sqlatable_no_dataset( + +def test_update_virtual_sqlatable_references( mocker: MockFixture, app_context: None, session: Session ) -> None: """ - Test updating the table on a physical dataset that it creates - a new dataset if one didn't already exist. + Test that changing the SQL of a virtual ``SqlaTable`` updates ``Dataset``. - When updating the table on a physical dataset by pointing it somewhere else (change - in database ID, schema, or table name) we should point the ``Dataset`` to an - existing ``Table`` if possible, and create a new one otherwise. + When the SQL is modified the list of referenced tables should be updated in the new + ``Dataset`` model. """ # patch session mocker.patch( "superset.security.SupersetSecurityManager.get_session", return_value=session ) - mocker.patch("superset.datasets.dao.db.session", session) from superset.columns.models import Column from superset.connectors.sqla.models import SqlaTable, TableColumn from superset.datasets.models import Dataset from superset.models.core import Database from superset.tables.models import Table - from superset.tables.schemas import TableSchema engine = session.get_bind() Dataset.metadata.create_all(engine) # pylint: disable=no-member - columns = [ - TableColumn(column_name="a", type="INTEGER"), - ] + database = Database(database_name="my_database", sqlalchemy_uri="sqlite://") + table1 = Table( + name="table_a", + schema="my_schema", + catalog=None, + database=database, + columns=[Column(name="a", type="INTEGER")], + ) + table2 = Table( + name="table_b", + schema="my_schema", + catalog=None, + database=database, + columns=[Column(name="b", type="INTEGER")], + ) + session.add(table1) + session.add(table2) + session.commit() + + # create virtual dataset + columns = [TableColumn(column_name="a", type="INTEGER")] sqla_table = SqlaTable( table_name="old_dataset", columns=columns, - metrics=[], - database=Database(database_name="my_database", sqlalchemy_uri="sqlite://"), + database=database, + schema="my_schema", + sql="SELECT a FROM table_a", ) session.add(sqla_table) session.flush() - # check that the table was created - table = session.query(Table).one() - assert table.id == 1 - - dataset = session.query(Dataset).one() - assert dataset.tables == [table] + # check that new dataset has table1 + dataset: Dataset = session.query(Dataset).one() + assert dataset.tables == [table1] - # point ``SqlaTable`` to a different database - new_database = Database( - database_name="my_other_database", sqlalchemy_uri="sqlite://" - ) - session.add(new_database) + # change SQL + sqla_table.sql = "SELECT a, b FROM table_a JOIN table_b" session.flush() - sqla_table.database = new_database + + # check that new dataset has both tables + new_dataset: Dataset = session.query(Dataset).one() + assert new_dataset.tables == [table1, table2] + assert new_dataset.expression == "SELECT a, b FROM table_a JOIN table_b" + + # automatically add new referenced table + sqla_table.sql = "SELECT a, b, c FROM table_a JOIN table_b JOIN table_c" session.flush() new_dataset = session.query(Dataset).one() + assert len(new_dataset.tables) == 3 + assert new_dataset.tables[2].name == "table_c" - # check that dataset now points to the new table - assert new_dataset.tables[0].database_id == 2 - # point ``SqlaTable`` back - sqla_table.database_id = 1 +def test_quote_expressions(app_context: None, session: Session) -> None: + """ + Test that expressions are quoted appropriately in columns and datasets. + """ + from superset.connectors.sqla.models import SqlaTable, TableColumn + from superset.datasets.models import Dataset + from superset.models.core import Database + + engine = session.get_bind() + Dataset.metadata.create_all(engine) # pylint: disable=no-member + + columns = [ + TableColumn(column_name="has space", type="INTEGER"), + TableColumn(column_name="no_need", type="INTEGER"), + ] + + sqla_table = SqlaTable( + table_name="old dataset", + columns=columns, + metrics=[], + database=Database(database_name="my_database", sqlalchemy_uri="sqlite://"), + ) + session.add(sqla_table) session.flush() - # check that dataset points to the original table - assert new_dataset.tables[0].database_id == 1 + dataset = session.query(Dataset).one() + assert dataset.expression == '"old dataset"' + assert dataset.columns[0].expression == '"has space"' + assert dataset.columns[1].expression == "no_need" diff --git a/tests/unit_tests/migrations/shared/__init__.py b/tests/unit_tests/jinja_context_test.py similarity index 72% rename from tests/unit_tests/migrations/shared/__init__.py rename to tests/unit_tests/jinja_context_test.py index 13a83393a9124..1f88f4f1a99c8 100644 --- a/tests/unit_tests/migrations/shared/__init__.py +++ b/tests/unit_tests/jinja_context_test.py @@ -14,3 +14,14 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. + +from superset.jinja_context import where_in + + +def test_where_in() -> None: + """ + Test the ``where_in`` Jinja2 filter. + """ + assert where_in([1, "b", 3]) == "(1, 'b', 3)" + assert where_in([1, "b", 3], '"') == '(1, "b", 3)' + assert where_in(["O'Malley's"]) == "('O''Malley''s')" diff --git a/tests/unit_tests/migrations/shared/utils_test.py b/tests/unit_tests/migrations/shared/utils_test.py deleted file mode 100644 index cb5b2cbd0e82b..0000000000000 --- a/tests/unit_tests/migrations/shared/utils_test.py +++ /dev/null @@ -1,56 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# pylint: disable=import-outside-toplevel, unused-argument - -""" -Test the SIP-68 migration. -""" - -from pytest_mock import MockerFixture - -from superset.sql_parse import Table - - -def test_extract_table_references(mocker: MockerFixture, app_context: None) -> None: - """ - Test the ``extract_table_references`` helper function. - """ - from superset.migrations.shared.utils import extract_table_references - - assert extract_table_references("SELECT 1", "trino") == set() - assert extract_table_references("SELECT 1 FROM some_table", "trino") == { - Table(table="some_table", schema=None, catalog=None) - } - assert extract_table_references( - "SELECT 1 FROM some_catalog.some_schema.some_table", "trino" - ) == {Table(table="some_table", schema="some_schema", catalog="some_catalog")} - assert extract_table_references( - "SELECT * FROM some_table JOIN other_table ON some_table.id = other_table.id", - "trino", - ) == { - Table(table="some_table", schema=None, catalog=None), - Table(table="other_table", schema=None, catalog=None), - } - - # test falling back to sqlparse - logger = mocker.patch("superset.migrations.shared.utils.logger") - sql = "SELECT * FROM table UNION ALL SELECT * FROM other_table" - assert extract_table_references( - sql, - "trino", - ) == {Table(table="other_table", schema=None, catalog=None)} - logger.warning.assert_called_with("Unable to parse query with sqloxide: %s", sql) diff --git a/tests/unit_tests/pandas_postprocessing/test_compare.py b/tests/unit_tests/pandas_postprocessing/test_compare.py index 970fa42f965e9..4f742bae16139 100644 --- a/tests/unit_tests/pandas_postprocessing/test_compare.py +++ b/tests/unit_tests/pandas_postprocessing/test_compare.py @@ -44,9 +44,9 @@ def test_compare_diff(): """ label y z difference__y__z 2019-01-01 x 2.0 2.0 0.0 - 2019-01-02 y 2.0 4.0 2.0 - 2019-01-05 z 2.0 10.0 8.0 - 2019-01-07 q 2.0 8.0 6.0 + 2019-01-02 y 2.0 4.0 -2.0 + 2019-01-05 z 2.0 10.0 -8.0 + 2019-01-07 q 2.0 8.0 -6.0 """ assert post_df.equals( pd.DataFrame( @@ -55,7 +55,7 @@ def test_compare_diff(): "label": ["x", "y", "z", "q"], "y": [2.0, 2.0, 2.0, 2.0], "z": [2.0, 4.0, 10.0, 8.0], - "difference__y__z": [0.0, 2.0, 8.0, 6.0], + "difference__y__z": [0.0, -2.0, -8.0, -6.0], }, ) ) @@ -73,7 +73,7 @@ def test_compare_diff(): index=timeseries_df2.index, data={ "label": ["x", "y", "z", "q"], - "difference__y__z": [0.0, 2.0, 8.0, 6.0], + "difference__y__z": [0.0, -2.0, -8.0, -6.0], }, ) ) @@ -90,9 +90,9 @@ def test_compare_percentage(): """ label y z percentage__y__z 2019-01-01 x 2.0 2.0 0.0 - 2019-01-02 y 2.0 4.0 1.0 - 2019-01-05 z 2.0 10.0 4.0 - 2019-01-07 q 2.0 8.0 3.0 + 2019-01-02 y 2.0 4.0 -0.50 + 2019-01-05 z 2.0 10.0 -0.80 + 2019-01-07 q 2.0 8.0 -0.75 """ assert post_df.equals( pd.DataFrame( @@ -101,7 +101,7 @@ def test_compare_percentage(): "label": ["x", "y", "z", "q"], "y": [2.0, 2.0, 2.0, 2.0], "z": [2.0, 4.0, 10.0, 8.0], - "percentage__y__z": [0.0, 1.0, 4.0, 3.0], + "percentage__y__z": [0.0, -0.50, -0.80, -0.75], }, ) ) @@ -117,10 +117,10 @@ def test_compare_ratio(): ) """ label y z ratio__y__z - 2019-01-01 x 2.0 2.0 1.0 - 2019-01-02 y 2.0 4.0 2.0 - 2019-01-05 z 2.0 10.0 5.0 - 2019-01-07 q 2.0 8.0 4.0 + 2019-01-01 x 2.0 2.0 1.00 + 2019-01-02 y 2.0 4.0 0.50 + 2019-01-05 z 2.0 10.0 0.20 + 2019-01-07 q 2.0 8.0 0.25 """ assert post_df.equals( pd.DataFrame( @@ -129,7 +129,7 @@ def test_compare_ratio(): "label": ["x", "y", "z", "q"], "y": [2.0, 2.0, 2.0, 2.0], "z": [2.0, 4.0, 10.0, 8.0], - "ratio__y__z": [1.0, 2.0, 5.0, 4.0], + "ratio__y__z": [1.00, 0.50, 0.20, 0.25], }, ) ) @@ -209,14 +209,14 @@ def test_compare_after_pivot(): difference__count_metric__sum_metric country UK US dttm - 2019-01-01 4 4 - 2019-01-02 4 4 + 2019-01-01 -4 -4 + 2019-01-02 -4 -4 """ flat_df = pp.flatten(compared_df) """ dttm difference__count_metric__sum_metric, UK difference__count_metric__sum_metric, US - 0 2019-01-01 4 4 - 1 2019-01-02 4 4 + 0 2019-01-01 -4 -4 + 1 2019-01-02 -4 -4 """ assert flat_df.equals( pd.DataFrame( @@ -224,10 +224,10 @@ def test_compare_after_pivot(): "dttm": pd.to_datetime(["2019-01-01", "2019-01-02"]), FLAT_COLUMN_SEPARATOR.join( ["difference__count_metric__sum_metric", "UK"] - ): [4, 4], + ): [-4, -4], FLAT_COLUMN_SEPARATOR.join( ["difference__count_metric__sum_metric", "US"] - ): [4, 4], + ): [-4, -4], } ) ) diff --git a/tests/unit_tests/pandas_postprocessing/test_flatten.py b/tests/unit_tests/pandas_postprocessing/test_flatten.py index 028d25e9ecdd0..78a2e3eea4421 100644 --- a/tests/unit_tests/pandas_postprocessing/test_flatten.py +++ b/tests/unit_tests/pandas_postprocessing/test_flatten.py @@ -18,6 +18,7 @@ from superset.utils import pandas_postprocessing as pp from superset.utils.pandas_postprocessing.utils import FLAT_COLUMN_SEPARATOR +from tests.unit_tests.fixtures.dataframes import timeseries_df def test_flat_should_not_change(): @@ -73,3 +74,85 @@ def test_flat_should_flat_multiple_index(): } ) ) + + +def test_flat_should_drop_index_level(): + index = pd.to_datetime(["2021-01-01", "2021-01-02", "2021-01-03"]) + index.name = "__timestamp" + columns = pd.MultiIndex.from_arrays( + [["a"] * 3, ["b"] * 3, ["c", "d", "e"], ["ff", "ii", "gg"]], + names=["level1", "level2", "level3", "level4"], + ) + df = pd.DataFrame(index=index, columns=columns, data=1) + + # drop level by index + assert pp.flatten(df.copy(), drop_levels=(0, 1,)).equals( + pd.DataFrame( + { + "__timestamp": index, + FLAT_COLUMN_SEPARATOR.join(["c", "ff"]): [1, 1, 1], + FLAT_COLUMN_SEPARATOR.join(["d", "ii"]): [1, 1, 1], + FLAT_COLUMN_SEPARATOR.join(["e", "gg"]): [1, 1, 1], + } + ) + ) + + # drop level by name + assert pp.flatten(df.copy(), drop_levels=("level1", "level2")).equals( + pd.DataFrame( + { + "__timestamp": index, + FLAT_COLUMN_SEPARATOR.join(["c", "ff"]): [1, 1, 1], + FLAT_COLUMN_SEPARATOR.join(["d", "ii"]): [1, 1, 1], + FLAT_COLUMN_SEPARATOR.join(["e", "gg"]): [1, 1, 1], + } + ) + ) + + # only leave 1 level + assert pp.flatten(df.copy(), drop_levels=(0, 1, 2)).equals( + pd.DataFrame( + { + "__timestamp": index, + FLAT_COLUMN_SEPARATOR.join(["ff"]): [1, 1, 1], + FLAT_COLUMN_SEPARATOR.join(["ii"]): [1, 1, 1], + FLAT_COLUMN_SEPARATOR.join(["gg"]): [1, 1, 1], + } + ) + ) + + +def test_flat_should_not_droplevel(): + assert pp.flatten(timeseries_df, drop_levels=(0,)).equals( + pd.DataFrame( + { + "index": pd.to_datetime( + ["2019-01-01", "2019-01-02", "2019-01-05", "2019-01-07"] + ), + "label": ["x", "y", "z", "q"], + "y": [1.0, 2.0, 3.0, 4.0], + } + ) + ) + + +def test_flat_integer_column_name(): + index = pd.to_datetime(["2021-01-01", "2021-01-02", "2021-01-03"]) + index.name = "__timestamp" + columns = pd.MultiIndex.from_arrays( + [["a"] * 3, [100, 200, 300]], + names=["level1", "level2"], + ) + df = pd.DataFrame(index=index, columns=columns, data=1) + assert pp.flatten(df, drop_levels=(0,)).equals( + pd.DataFrame( + { + "__timestamp": pd.to_datetime( + ["2021-01-01", "2021-01-02", "2021-01-03"] + ), + "100": [1, 1, 1], + "200": [1, 1, 1], + "300": [1, 1, 1], + } + ) + ) diff --git a/tests/unit_tests/pandas_postprocessing/test_rename.py b/tests/unit_tests/pandas_postprocessing/test_rename.py new file mode 100644 index 0000000000000..f49680a352618 --- /dev/null +++ b/tests/unit_tests/pandas_postprocessing/test_rename.py @@ -0,0 +1,175 @@ +# 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 pandas as pd +import pytest + +from superset.exceptions import InvalidPostProcessingError +from superset.utils import pandas_postprocessing as pp +from tests.unit_tests.fixtures.dataframes import categories_df + + +def test_rename_should_not_side_effect(): + _categories_df = categories_df.copy() + pp.rename( + df=_categories_df, + columns={ + "constant": "constant_newname", + "category": "category_namename", + }, + ) + assert _categories_df.equals(categories_df) + + +def test_rename(): + new_categories_df = pp.rename( + df=categories_df, + columns={ + "constant": "constant_newname", + "category": "category_newname", + }, + ) + assert list(new_categories_df.columns.values) == [ + "constant_newname", + "category_newname", + "dept", + "name", + "asc_idx", + "desc_idx", + "idx_nulls", + ] + assert not new_categories_df.equals(categories_df) + + +def test_should_inplace_rename(): + _categories_df = categories_df.copy() + _categories_df_inplaced = pp.rename( + df=_categories_df, + columns={ + "constant": "constant_newname", + "category": "category_namename", + }, + inplace=True, + ) + assert _categories_df_inplaced.equals(_categories_df) + + +def test_should_rename_on_level(): + iterables = [["m1", "m2"], ["a", "b"], ["x", "y"]] + columns = pd.MultiIndex.from_product(iterables, names=[None, "level1", "level2"]) + df = pd.DataFrame(index=[0, 1, 2], columns=columns, data=1) + """ + m1 m2 + level1 a b a b + level2 x y x y x y x y + 0 1 1 1 1 1 1 1 1 + 1 1 1 1 1 1 1 1 1 + 2 1 1 1 1 1 1 1 1 + """ + post_df = pp.rename( + df=df, + columns={"m1": "new_m1"}, + level=0, + ) + assert post_df.columns.get_level_values(level=0).equals( + pd.Index( + [ + "new_m1", + "new_m1", + "new_m1", + "new_m1", + "m2", + "m2", + "m2", + "m2", + ] + ) + ) + + +def test_should_raise_exception_no_column(): + with pytest.raises(InvalidPostProcessingError): + pp.rename( + df=categories_df, + columns={ + "foobar": "foobar2", + }, + ) + + +def test_should_raise_exception_duplication(): + with pytest.raises(InvalidPostProcessingError): + pp.rename( + df=categories_df, + columns={ + "constant": "category", + }, + ) + + +def test_should_raise_exception_duplication_on_multiindx(): + iterables = [["m1", "m2"], ["a", "b"], ["x", "y"]] + columns = pd.MultiIndex.from_product(iterables, names=[None, "level1", "level2"]) + df = pd.DataFrame(index=[0, 1, 2], columns=columns, data=1) + """ + m1 m2 + level1 a b a b + level2 x y x y x y x y + 0 1 1 1 1 1 1 1 1 + 1 1 1 1 1 1 1 1 1 + 2 1 1 1 1 1 1 1 1 + """ + + with pytest.raises(InvalidPostProcessingError): + pp.rename( + df=df, + columns={ + "m1": "m2", + }, + level=0, + ) + pp.rename( + df=df, + columns={ + "a": "b", + }, + level=1, + ) + + +def test_should_raise_exception_invalid_level(): + with pytest.raises(InvalidPostProcessingError): + pp.rename( + df=categories_df, + columns={ + "constant": "new_constant", + }, + level=100, + ) + pp.rename( + df=categories_df, + columns={ + "constant": "new_constant", + }, + level="xxxxx", + ) + + +def test_should_return_df_empty_columns(): + assert pp.rename( + df=categories_df, + columns={}, + ).equals(categories_df) diff --git a/tests/unit_tests/sql_parse_tests.py b/tests/unit_tests/sql_parse_tests.py index 4a1ff89d74cc6..d9c5d64c5950c 100644 --- a/tests/unit_tests/sql_parse_tests.py +++ b/tests/unit_tests/sql_parse_tests.py @@ -29,6 +29,7 @@ from superset.exceptions import QueryClauseValidationException from superset.sql_parse import ( add_table_name, + extract_table_references, get_rls_for_table, has_table_query, insert_rls, @@ -1468,3 +1469,51 @@ def test_get_rls_for_table(mocker: MockerFixture, app_context: None) -> None: dataset.get_sqla_row_level_filters.return_value = [] assert get_rls_for_table(candidate, 1, "public") is None + + +def test_extract_table_references(mocker: MockerFixture) -> None: + """ + Test the ``extract_table_references`` helper function. + """ + assert extract_table_references("SELECT 1", "trino") == set() + assert extract_table_references("SELECT 1 FROM some_table", "trino") == { + Table(table="some_table", schema=None, catalog=None) + } + assert extract_table_references("SELECT {{ jinja }} FROM some_table", "trino") == { + Table(table="some_table", schema=None, catalog=None) + } + assert extract_table_references( + "SELECT 1 FROM some_catalog.some_schema.some_table", "trino" + ) == {Table(table="some_table", schema="some_schema", catalog="some_catalog")} + + # with identifier quotes + assert extract_table_references( + "SELECT 1 FROM `some_catalog`.`some_schema`.`some_table`", "mysql" + ) == {Table(table="some_table", schema="some_schema", catalog="some_catalog")} + assert extract_table_references( + 'SELECT 1 FROM "some_catalog".some_schema."some_table"', "trino" + ) == {Table(table="some_table", schema="some_schema", catalog="some_catalog")} + + assert extract_table_references( + "SELECT * FROM some_table JOIN other_table ON some_table.id = other_table.id", + "trino", + ) == { + Table(table="some_table", schema=None, catalog=None), + Table(table="other_table", schema=None, catalog=None), + } + + # test falling back to sqlparse + logger = mocker.patch("superset.sql_parse.logger") + sql = "SELECT * FROM table UNION ALL SELECT * FROM other_table" + assert extract_table_references( + sql, + "trino", + ) == {Table(table="other_table", schema=None, catalog=None)} + logger.warning.assert_called_once() + + logger = mocker.patch("superset.migrations.shared.utils.logger") + sql = "SELECT * FROM table UNION ALL SELECT * FROM other_table" + assert extract_table_references(sql, "trino", show_warning=False) == { + Table(table="other_table", schema=None, catalog=None) + } + logger.warning.assert_not_called() diff --git a/tests/unit_tests/test_jinja_context.py b/tests/unit_tests/test_jinja_context.py new file mode 100644 index 0000000000000..7c301c88ea3e5 --- /dev/null +++ b/tests/unit_tests/test_jinja_context.py @@ -0,0 +1,268 @@ +# 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 json +from typing import Any + +import pytest +from flask.ctx import AppContext +from sqlalchemy.dialects.postgresql import dialect + +from superset import app +from superset.exceptions import SupersetTemplateException +from superset.jinja_context import ExtraCache, safe_proxy + + +def test_filter_values_default(app_context: AppContext) -> None: + cache = ExtraCache() + assert cache.filter_values("name", "foo") == ["foo"] + assert cache.removed_filters == [] + + +def test_filter_values_remove_not_present(app_context: AppContext) -> None: + cache = ExtraCache() + assert cache.filter_values("name", remove_filter=True) == [] + assert cache.removed_filters == [] + + +def test_get_filters_remove_not_present(app_context: AppContext) -> None: + cache = ExtraCache() + assert cache.get_filters("name", remove_filter=True) == [] + assert cache.removed_filters == [] + + +def test_filter_values_no_default(app_context: AppContext) -> None: + cache = ExtraCache() + assert cache.filter_values("name") == [] + + +def test_filter_values_adhoc_filters(app_context: AppContext) -> None: + with app.test_request_context( + data={ + "form_data": json.dumps( + { + "adhoc_filters": [ + { + "clause": "WHERE", + "comparator": "foo", + "expressionType": "SIMPLE", + "operator": "in", + "subject": "name", + } + ], + } + ) + } + ): + cache = ExtraCache() + assert cache.filter_values("name") == ["foo"] + assert cache.applied_filters == ["name"] + + with app.test_request_context( + data={ + "form_data": json.dumps( + { + "adhoc_filters": [ + { + "clause": "WHERE", + "comparator": ["foo", "bar"], + "expressionType": "SIMPLE", + "operator": "in", + "subject": "name", + } + ], + } + ) + } + ): + cache = ExtraCache() + assert cache.filter_values("name") == ["foo", "bar"] + assert cache.applied_filters == ["name"] + + +def test_get_filters_adhoc_filters(app_context: AppContext) -> None: + with app.test_request_context( + data={ + "form_data": json.dumps( + { + "adhoc_filters": [ + { + "clause": "WHERE", + "comparator": "foo", + "expressionType": "SIMPLE", + "operator": "in", + "subject": "name", + } + ], + } + ) + } + ): + cache = ExtraCache() + assert cache.get_filters("name") == [ + {"op": "IN", "col": "name", "val": ["foo"]} + ] + + assert cache.removed_filters == [] + assert cache.applied_filters == ["name"] + + with app.test_request_context( + data={ + "form_data": json.dumps( + { + "adhoc_filters": [ + { + "clause": "WHERE", + "comparator": ["foo", "bar"], + "expressionType": "SIMPLE", + "operator": "in", + "subject": "name", + } + ], + } + ) + } + ): + cache = ExtraCache() + assert cache.get_filters("name") == [ + {"op": "IN", "col": "name", "val": ["foo", "bar"]} + ] + assert cache.removed_filters == [] + + with app.test_request_context( + data={ + "form_data": json.dumps( + { + "adhoc_filters": [ + { + "clause": "WHERE", + "comparator": ["foo", "bar"], + "expressionType": "SIMPLE", + "operator": "in", + "subject": "name", + } + ], + } + ) + } + ): + cache = ExtraCache() + assert cache.get_filters("name", remove_filter=True) == [ + {"op": "IN", "col": "name", "val": ["foo", "bar"]} + ] + assert cache.removed_filters == ["name"] + assert cache.applied_filters == ["name"] + + +def test_filter_values_extra_filters(app_context: AppContext) -> None: + with app.test_request_context( + data={ + "form_data": json.dumps( + {"extra_filters": [{"col": "name", "op": "in", "val": "foo"}]} + ) + } + ): + cache = ExtraCache() + assert cache.filter_values("name") == ["foo"] + assert cache.applied_filters == ["name"] + + +def test_url_param_default(app_context: AppContext) -> None: + with app.test_request_context(): + cache = ExtraCache() + assert cache.url_param("foo", "bar") == "bar" + + +def test_url_param_no_default(app_context: AppContext) -> None: + with app.test_request_context(): + cache = ExtraCache() + assert cache.url_param("foo") is None + + +def test_url_param_query(app_context: AppContext) -> None: + with app.test_request_context(query_string={"foo": "bar"}): + cache = ExtraCache() + assert cache.url_param("foo") == "bar" + + +def test_url_param_form_data(app_context: AppContext) -> None: + with app.test_request_context( + query_string={"form_data": json.dumps({"url_params": {"foo": "bar"}})} + ): + cache = ExtraCache() + assert cache.url_param("foo") == "bar" + + +def test_url_param_escaped_form_data(app_context: AppContext) -> None: + with app.test_request_context( + query_string={"form_data": json.dumps({"url_params": {"foo": "O'Brien"}})} + ): + cache = ExtraCache(dialect=dialect()) + assert cache.url_param("foo") == "O''Brien" + + +def test_url_param_escaped_default_form_data(app_context: AppContext) -> None: + with app.test_request_context( + query_string={"form_data": json.dumps({"url_params": {"foo": "O'Brien"}})} + ): + cache = ExtraCache(dialect=dialect()) + assert cache.url_param("bar", "O'Malley") == "O''Malley" + + +def test_url_param_unescaped_form_data(app_context: AppContext) -> None: + with app.test_request_context( + query_string={"form_data": json.dumps({"url_params": {"foo": "O'Brien"}})} + ): + cache = ExtraCache(dialect=dialect()) + assert cache.url_param("foo", escape_result=False) == "O'Brien" + + +def test_url_param_unescaped_default_form_data(app_context: AppContext) -> None: + with app.test_request_context( + query_string={"form_data": json.dumps({"url_params": {"foo": "O'Brien"}})} + ): + cache = ExtraCache(dialect=dialect()) + assert cache.url_param("bar", "O'Malley", escape_result=False) == "O'Malley" + + +def test_safe_proxy_primitive(app_context: AppContext) -> None: + def func(input_: Any) -> Any: + return input_ + + assert safe_proxy(func, "foo") == "foo" + + +def test_safe_proxy_dict(app_context: AppContext) -> None: + def func(input_: Any) -> Any: + return input_ + + assert safe_proxy(func, {"foo": "bar"}) == {"foo": "bar"} + + +def test_safe_proxy_lambda(app_context: AppContext) -> None: + def func(input_: Any) -> Any: + return input_ + + with pytest.raises(SupersetTemplateException): + safe_proxy(func, lambda: "bar") + + +def test_safe_proxy_nested_lambda(app_context: AppContext) -> None: + def func(input_: Any) -> Any: + return input_ + + with pytest.raises(SupersetTemplateException): + safe_proxy(func, {"foo": lambda: "bar"}) diff --git a/tests/unit_tests/utils/db.py b/tests/unit_tests/utils/db.py new file mode 100644 index 0000000000000..554c95bd43187 --- /dev/null +++ b/tests/unit_tests/utils/db.py @@ -0,0 +1,30 @@ +# 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 typing import Any + +from superset import security_manager + + +def get_test_user(id_: int, username: str) -> Any: + """Create a sample test user""" + return security_manager.user_model( + id=id_, + username=username, + first_name=username, + last_name=username, + email=f"{username}@example.com", + )