diff --git a/.github/workflows/superset-python-unittest.yml b/.github/workflows/superset-python-unittest.yml index 1ff07375d4727..8c94d0f4582cd 100644 --- a/.github/workflows/superset-python-unittest.yml +++ b/.github/workflows/superset-python-unittest.yml @@ -50,6 +50,8 @@ jobs: mkdir ${{ github.workspace }}/.temp - name: Python unit tests if: steps.check.outcome == 'failure' + env: + SUPERSET_TESTENV: true run: | pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear - name: Upload code coverage diff --git a/UPDATING.md b/UPDATING.md index 4c6f62e89d74b..c29b7182a0534 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -24,6 +24,18 @@ assists people when migrating to a new version. ## Next +- [22809](https://github.com/apache/superset/pull/22809): Migrated endpoint `/superset/sql_json` and `/superset/results/` to `/api/v1/sqllab/execute/` and `/api/v1/sqllab/results/` respectively. Corresponding permissions are `can sql_json on Superset` to `can execute on SQLLab`, `can results on Superset` to `can results on SQLLab`. Make sure you add/replace the necessary permissions on any custom roles you may have. +- [22931](https://github.com/apache/superset/pull/22931): Migrated endpoint `/superset/get_or_create_table/` to `/api/v1/dataset/get_or_create/`. Corresponding permissions are `can get or create table on Superset` to `can get or create dataset on Dataset`. Make sure you add/replace the necessary permissions on any custom roles you may have. +- [22882](https://github.com/apache/superset/pull/22882): Migrated endpoint `/superset/filter////` to `/api/v1/datasource///column//values/`. Corresponding permissions are `can filter on Superset` to `can get column values on Datasource`. Make sure you add/replace the necessary permissions on any custom roles you may have. +- [22789](https://github.com/apache/superset/pull/22789): Migrated endpoint `/superset/recent_activity//` to `/api/v1/log/recent_activity//`. Corresponding permissions are `can recent activity on Superset` to `can recent activity on Log`. Make sure you add/replace the necessary permissions on any custom roles you may have. +- [22913](https://github.com/apache/superset/pull/22913): Migrated endpoint `/superset/csv` to `/api/v1/sqllab/export/`. Corresponding permissions are `can csv on Superset` to `can export csv on SQLLab`. Make sure you add/replace the necessary permissions on any custom roles you may have. +- [22496](https://github.com/apache/superset/pull/22496): Migrated endpoint `/superset/slice_json/` to `/api/v1/chart//data/`. Corresponding permissions are `can slice json on Superset` to `can read on Chart`. Make sure you add/replace the necessary permissions on any custom roles you may have. +- [22496](https://github.com/apache/superset/pull/22496): Migrated endpoint `/superset/annotation_json/` to `/api/v1/chart//data/`. Corresponding permissions are `can annotation json on Superset` to `can read on Chart`. Make sure you add/replace the necessary permissions on any custom roles you may have. +- [22624](https://github.com/apache/superset/pull/22624): Migrated endpoint `/superset/stop_query/` to `/api/v1/query/stop`. Corresponding permissions are `can stop query on Superset` to `can read on Query`. Make sure you add/replace the necessary permissions on any custom roles you may have. +- [22579](https://github.com/apache/superset/pull/22579): Migrated endpoint `/superset/search_queries/` to `/api/v1/query/`. Corresponding permissions are `can search queries on Superset` to `can read on Query`. Make sure you add/replace the necessary permissions on any custom roles you may have. +- [22501](https://github.com/apache/superset/pull/22501): Migrated endpoint `/superset/tables///` to `/api/v1/database//tables/`. Corresponding permissions are `can tables on Superset` to `can read on Database`. Make sure you add/replace the necessary permissions on any custom roles you may have. +- [22611](https://github.com/apache/superset/pull/22611): Migrated endpoint `/superset/queries/` to `api/v1/query/updated_since`. Corresponding permissions are `can queries on Superset` to `can read on Query`. Make sure you add/replace the necessary permissions on any custom roles you may have. +- [23186](https://github.com/apache/superset/pull/23186): Superset will refuse to start if a default `SECRET_KEY` is detected on a non Flask debug setting. - [22022](https://github.com/apache/superset/pull/22022): HTTP API endpoints `/superset/approve` and `/superset/request_access` have been deprecated and their HTTP methods were changed from GET to POST - [20606](https://github.com/apache/superset/pull/20606): When user clicks on chart title or "Edit chart" button in Dashboard page, Explore opens in the same tab. Clicking while holding cmd/ctrl opens Explore in a new tab. To bring back the old behaviour (always opening Explore in a new tab), flip feature flag `DASHBOARD_EDIT_CHART_IN_NEW_TAB` to `True`. - [20799](https://github.com/apache/superset/pull/20799): Presto and Trino engine will now display tracking URL for running queries in SQL Lab. If for some reason you don't want to show the tracking URL (for example, when your data warehouse hasn't enabled access for to Presto or Trino UI), update `TRACKING_URL_TRANSFORMER` in `config.py` to return `None`. diff --git a/docker/.env-non-dev b/docker/.env-non-dev index 0ae4c1c7932bb..726b0bb167bf7 100644 --- a/docker/.env-non-dev +++ b/docker/.env-non-dev @@ -42,6 +42,7 @@ REDIS_PORT=6379 FLASK_ENV=production SUPERSET_ENV=production SUPERSET_LOAD_EXAMPLES=yes +SUPERSET_SECRET_KEY=TEST_NON_DEV_SECRET CYPRESS_CONFIG=false SUPERSET_PORT=8088 MAPBOX_API_KEY='' diff --git a/docs/docs/installation/alerts-reports.mdx b/docs/docs/installation/alerts-reports.mdx index a193f6ff26593..06ebcbd7525a0 100644 --- a/docs/docs/installation/alerts-reports.mdx +++ b/docs/docs/installation/alerts-reports.mdx @@ -193,15 +193,15 @@ creator if either is contained within the list of owners, otherwise the first ow will be used) and finally `THUMBNAIL_SELENIUM_USER`, set as follows: ```python -from superset.reports.types import ReportScheduleExecutor +from superset.tasks.types import ExecutorType ALERT_REPORTS_EXECUTE_AS = [ - ReportScheduleExecutor.CREATOR_OWNER, - ReportScheduleExecutor.CREATOR, - ReportScheduleExecutor.MODIFIER_OWNER, - ReportScheduleExecutor.MODIFIER, - ReportScheduleExecutor.OWNER, - ReportScheduleExecutor.SELENIUM, + ExecutorType.CREATOR_OWNER, + ExecutorType.CREATOR, + ExecutorType.MODIFIER_OWNER, + ExecutorType.MODIFIER, + ExecutorType.OWNER, + ExecutorType.SELENIUM, ] ``` diff --git a/docs/docs/installation/configuring-superset.mdx b/docs/docs/installation/configuring-superset.mdx index aefc12d603061..916c28b49522d 100644 --- a/docs/docs/installation/configuring-superset.mdx +++ b/docs/docs/installation/configuring-superset.mdx @@ -23,8 +23,8 @@ SUPERSET_WEBSERVER_PORT = 8088 # Your App secret key will be used for securely signing the session cookie # and encrypting sensitive information on the database # Make sure you are changing this key for your deployment with a strong key. -# You can generate a strong key using `openssl rand -base64 42` - +# You can generate a strong key using `openssl rand -base64 42`. +# Alternatively you can set it with `SUPERSET_SECRET_KEY` environment variable. SECRET_KEY = 'YOUR_OWN_RANDOM_GENERATED_SECRET_KEY' # The SQLAlchemy connection string to your database backend diff --git a/docs/docs/installation/installing-superset-from-scratch.mdx b/docs/docs/installation/installing-superset-from-scratch.mdx index c5f051087fc42..2dd34bc48d67e 100644 --- a/docs/docs/installation/installing-superset-from-scratch.mdx +++ b/docs/docs/installation/installing-superset-from-scratch.mdx @@ -138,6 +138,12 @@ superset load_examples # Create default roles and permissions superset init +# Build javascript assets +cd superset-frontend +npm ci +npm run build +cd .. + # To start a development web server on port 8088, use -p to bind to another port superset run -p 8088 --with-threads --reload --debugger ``` diff --git a/docs/docs/installation/sql-templating.mdx b/docs/docs/installation/sql-templating.mdx index 72c2c0a9adb75..768c0e7a53c67 100644 --- a/docs/docs/installation/sql-templating.mdx +++ b/docs/docs/installation/sql-templating.mdx @@ -30,7 +30,9 @@ made available in the Jinja context: For example, to add a time range to a virtual dataset, you can write the following: ```sql -SELECT * from tbl where dttm_col > '{{ from_dttm }}' and dttm_col < '{{ to_dttm }}' +SELECT * +FROM tbl +WHERE dttm_col > '{{ from_dttm }}' and dttm_col < '{{ to_dttm }}' ``` You can also use [Jinja's logic](https://jinja.palletsprojects.com/en/2.11.x/templates/#tests) @@ -64,6 +66,41 @@ JINJA_CONTEXT_ADDONS = { } ``` +Default values for jinja templates can be specified via `Parameters` menu in the SQL Lab user interface. +In the UI you can assign a set of parameters as JSON + +```json +{ + "my_table": "foo" +} +``` +The parameters become available in your SQL (example: `SELECT * FROM {{ my_table }}` ) by using Jinja templating syntax. +SQL Lab template parameters are stored with the dataset as `TEMPLATE PARAMETERS`. + +There is a special ``_filters`` parameter which can be used to test filters used in the jinja template. + +```json +{ + "_filters": [ + { + "col": "action_type", + "op": "IN", + "val": ["sell", "buy"] + } + ] +} +``` + +```sql +SELECT action, count(*) as times +FROM logs +WHERE action in {{ filter_values('action_type'))|where_in }} +GROUP BY action +``` + +Note ``_filters`` is not stored with the dataset. It's only used within the SQL Lab UI. + + Besides default Jinja templating, SQL lab also supports self-defined template processor by setting the `CUSTOM_TEMPLATE_PROCESSORS` in your superset configuration. The values in this dictionary overwrite the default Jinja template processors of the specified database engine. The example below @@ -174,7 +211,7 @@ Here's a concrete example: - You write the following query in SQL Lab: - ``` + ```sql SELECT count(*) FROM ORDERS WHERE country_code = '{{ url_param('countrycode') }}' @@ -185,7 +222,7 @@ Here's a concrete example: and your coworker in the USA the following SQL Lab URL `www.example.com/superset/sqllab?countrycode=US` - For your coworker in Spain, the SQL Lab query will be rendered as: - ``` + ```sql SELECT count(*) FROM ORDERS WHERE country_code = 'ES' @@ -193,7 +230,7 @@ Here's a concrete example: - For your coworker in the USA, the SQL Lab query will be rendered as: - ``` + ```sql SELECT count(*) FROM ORDERS WHERE country_code = 'US' @@ -222,7 +259,7 @@ This is useful if: Here's a concrete example: -``` +```sql SELECT action, count(*) as times FROM logs WHERE diff --git a/docs/docs/security.mdx b/docs/docs/security.mdx index e868de6a99aff..b73e6db5722c5 100644 --- a/docs/docs/security.mdx +++ b/docs/docs/security.mdx @@ -148,7 +148,7 @@ a certain resource type or policy area. You can check possible directives It's extremely important to correclty configure a Content Security Policy when deploying Superset to prevent many types of attacks. For that matter, Superset provides the ` TALISMAN_CONFIG` key in `config.py` -where admnistrators can define the policy. When running in production mode, Superset will check for the presence +where administrators can define the policy. When running in production mode, Superset will check for the presence of a policy and if it's not able to find one, it will issue a warning with the security risks. For environments where CSP policies are defined outside of Superset using other software, administrators can disable the warning using the `CONTENT_SECURITY_POLICY_WARNING` key in `config.py`. diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index e6121cca9348b..db875088c0ddb 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -247,7 +247,14 @@ const config = { darkTheme: darkCodeTheme, }, }), - scripts: ['/script/matomo.js'], + scripts: [ + '/script/matomo.js', + { + src: + 'https://www.bugherd.com/sidebarv2.js?apikey=enilpiu7bgexxsnoqfjtxa', + async: true, + }, + ], }; module.exports = config; diff --git a/docs/package.json b/docs/package.json index a8e2e4e1c69e2..5128299f62e66 100644 --- a/docs/package.json +++ b/docs/package.json @@ -18,11 +18,11 @@ "dependencies": { "@algolia/client-search": "^4.13.0", "@ant-design/icons": "^4.7.0", - "@docsearch/react": "^3.0.0", - "@docusaurus/core": "^2.0.0-beta.17", - "@docusaurus/plugin-client-redirects": "^2.0.0-beta.17", - "@docusaurus/plugin-google-gtag": "^2.0.0-beta.18", - "@docusaurus/preset-classic": "^2.0.0-beta.17", + "@docsearch/react": "^3.3.3", + "@docusaurus/core": "^2.3.1", + "@docusaurus/plugin-client-redirects": "^2.3.1", + "@docusaurus/plugin-google-gtag": "^2.3.1", + "@docusaurus/preset-classic": "^2.3.1", "@emotion/core": "^10.1.1", "@emotion/styled": "^10.0.27", "@mdx-js/react": "^1.6.22", @@ -45,8 +45,8 @@ "url-loader": "^4.1.1" }, "devDependencies": { - "@docusaurus/module-type-aliases": "^2.0.0-beta.17", - "@tsconfig/docusaurus": "^1.0.4", + "@docusaurus/module-type-aliases": "^2.3.1", + "@tsconfig/docusaurus": "^1.0.6", "@types/react": "^17.0.42", "typescript": "^4.3.5", "webpack": "^5.61.0" diff --git a/requirements/integration.txt b/requirements/integration.txt index 3094ec5907f22..995e5d09d4458 100644 --- a/requirements/integration.txt +++ b/requirements/integration.txt @@ -29,7 +29,7 @@ packaging==21.3 # tox pep517==0.11.0 # via build -pip-compile-multi==2.6.1 +pip-compile-multi==2.6.2 # via -r integration.in pip-tools==6.8.0 # via pip-compile-multi diff --git a/setup.py b/setup.py index f681d640fd038..c6850070a0a71 100644 --- a/setup.py +++ b/setup.py @@ -135,7 +135,7 @@ def get_git_sha() -> str: "sqlalchemy-bigquery>=1.5.0", "google-cloud-bigquery>=3.4.0", ], - "clickhouse": ["clickhouse-connect>=0.4.6, <0.5"], + "clickhouse": ["clickhouse-connect>=0.5.14, <1.0"], "cockroachdb": ["cockroachdb>=0.3.5, <0.4"], "cors": ["flask-cors>=2.0.0"], "crate": ["crate[sqlalchemy]>=0.26.0, <0.27"], diff --git a/superset-embedded-sdk/package-lock.json b/superset-embedded-sdk/package-lock.json index 826fb282c9e28..a8912e95dc8a0 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.8", + "version": "0.1.0-alpha.9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@superset-ui/embedded-sdk", - "version": "0.1.0-alpha.8", + "version": "0.1.0-alpha.9", "license": "Apache-2.0", "dependencies": { "@superset-ui/switchboard": "^0.18.26-0", diff --git a/superset-embedded-sdk/package.json b/superset-embedded-sdk/package.json index 055f44191a7e8..4ee8510a051af 100644 --- a/superset-embedded-sdk/package.json +++ b/superset-embedded-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@superset-ui/embedded-sdk", - "version": "0.1.0-alpha.8", + "version": "0.1.0-alpha.9", "description": "SDK for embedding resources from Superset into your own application", "access": "public", "keywords": [ diff --git a/superset-embedded-sdk/src/index.ts b/superset-embedded-sdk/src/index.ts index 56a07e5544c1d..d2513422fa104 100644 --- a/superset-embedded-sdk/src/index.ts +++ b/superset-embedded-sdk/src/index.ts @@ -150,6 +150,7 @@ export async function embedDashboard({ }); iframe.src = `${supersetDomain}/embedded/${id}${dashboardConfig}${filterConfigUrlParams}`; + //@ts-ignore mountPoint.replaceChildren(iframe); log('placed the iframe') }); @@ -173,6 +174,7 @@ export async function embedDashboard({ function unmount() { log('unmounting'); + //@ts-ignore mountPoint.replaceChildren(); } diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/load.test.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/load.test.ts index bf60c2bec1a7e..78c635cc04d23 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/load.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/load.test.ts @@ -42,7 +42,8 @@ describe('Dashboard load', () => { cy.get('#app-menu').should('not.exist'); }); - it('should send log data', () => { + // TODO flaky test. skipping to unblock CI + it.skip('should send log data', () => { interceptLog(); cy.visit(WORLD_HEALTH_DASHBOARD); cy.wait('@logs', { timeout: 15000 }); 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 e934a47bfb68f..1e119ae775c21 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts @@ -119,7 +119,6 @@ function prepareDashboardFilters( }); if (dashboardId) { const jsonMetadata = { - show_native_filters: true, native_filter_configuration: allFilters, timed_refresh_immune_slices: [], expanded_slices: {}, @@ -379,10 +378,13 @@ describe('Horizontal FilterBar', () => { { name: 'test_12', column: 'year', datasetId: 2 }, ]); setFilterBarOrientation('horizontal'); + openMoreFilters(); + applyNativeFilterValueWithIndex(8, testItems.filterDefaultValue); + cy.get(nativeFilters.applyFilter).click({ force: true }); cy.getBySel('slice-header').within(() => { - cy.get('.filter-counts').click(); + cy.get('.filter-counts').trigger('mouseover'); }); - cy.get('.filterStatusPopover').contains('test_8').click(); + cy.get('.filterStatusPopover').contains('test_9').click(); cy.getBySel('dropdown-content').should('be.visible'); cy.get('.ant-select-focused').should('be.visible'); }); diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 76d74c18cb766..2d3d67aa7436e 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -13,7 +13,7 @@ "plugins/*" ], "dependencies": { - "@ant-design/icons": "^4.8.0", + "@ant-design/icons": "^5.0.1", "@babel/runtime-corejs3": "^7.12.5", "@emotion/babel-preset-css-prop": "^11.2.0", "@emotion/cache": "^11.4.0", @@ -364,19 +364,19 @@ } }, "node_modules/@ant-design/colors": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz", - "integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.0.0.tgz", + "integrity": "sha512-iVm/9PfGCbC0dSMBrz7oiEXZaaGH7ceU40OJEfKmyuzR9R5CRimJYPlRiFtMQGQcbNMea/ePcoIebi4ASGYXtg==", "dependencies": { "@ctrl/tinycolor": "^3.4.0" } }, "node_modules/@ant-design/icons": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-4.8.0.tgz", - "integrity": "sha512-T89P2jG2vM7OJ0IfGx2+9FC5sQjtTzRSz+mCHTXkFn/ELZc2YpfStmYHmqzq2Jx55J0F7+O6i5/ZKFSVNWCKNg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.0.1.tgz", + "integrity": "sha512-ZyF4ksXCcdtwA/1PLlnFLcF/q8/MhwxXhKHh4oCHDA4Ip+ZzAHoICtyp4wZWfiCVDP0yuz3HsjyvuldHFb3wjA==", "dependencies": { - "@ant-design/colors": "^6.0.0", + "@ant-design/colors": "^7.0.0", "@ant-design/icons-svg": "^4.2.1", "@babel/runtime": "^7.11.2", "classnames": "^2.2.6", @@ -23410,6 +23410,33 @@ "@ctrl/tinycolor": "^3.3.1" } }, + "node_modules/antd/node_modules/@ant-design/icons": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-4.8.0.tgz", + "integrity": "sha512-T89P2jG2vM7OJ0IfGx2+9FC5sQjtTzRSz+mCHTXkFn/ELZc2YpfStmYHmqzq2Jx55J0F7+O6i5/ZKFSVNWCKNg==", + "dependencies": { + "@ant-design/colors": "^6.0.0", + "@ant-design/icons-svg": "^4.2.1", + "@babel/runtime": "^7.11.2", + "classnames": "^2.2.6", + "rc-util": "^5.9.4" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/antd/node_modules/@ant-design/icons/node_modules/@ant-design/colors": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz", + "integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==", + "dependencies": { + "@ctrl/tinycolor": "^3.4.0" + } + }, "node_modules/antd/node_modules/@ant-design/react-slick": { "version": "0.28.4", "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-0.28.4.tgz", @@ -59890,7 +59917,7 @@ "prop-types": "^15.7.2" }, "peerDependencies": { - "@ant-design/icons": "^4.2.2", + "@ant-design/icons": "^5.0.1", "@emotion/react": "^11.4.1", "@superset-ui/core": "*", "@testing-library/dom": "^7.29.4", @@ -61373,7 +61400,7 @@ "jest": "^26.0.1" }, "peerDependencies": { - "@ant-design/icons": "^4.2.2", + "@ant-design/icons": "^5.0.1", "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", "lodash": "^4.17.11", @@ -61677,19 +61704,19 @@ } }, "@ant-design/colors": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz", - "integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.0.0.tgz", + "integrity": "sha512-iVm/9PfGCbC0dSMBrz7oiEXZaaGH7ceU40OJEfKmyuzR9R5CRimJYPlRiFtMQGQcbNMea/ePcoIebi4ASGYXtg==", "requires": { "@ctrl/tinycolor": "^3.4.0" } }, "@ant-design/icons": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-4.8.0.tgz", - "integrity": "sha512-T89P2jG2vM7OJ0IfGx2+9FC5sQjtTzRSz+mCHTXkFn/ELZc2YpfStmYHmqzq2Jx55J0F7+O6i5/ZKFSVNWCKNg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.0.1.tgz", + "integrity": "sha512-ZyF4ksXCcdtwA/1PLlnFLcF/q8/MhwxXhKHh4oCHDA4Ip+ZzAHoICtyp4wZWfiCVDP0yuz3HsjyvuldHFb3wjA==", "requires": { - "@ant-design/colors": "^6.0.0", + "@ant-design/colors": "^7.0.0", "@ant-design/icons-svg": "^4.2.1", "@babel/runtime": "^7.11.2", "classnames": "^2.2.6", @@ -80359,6 +80386,28 @@ "@ctrl/tinycolor": "^3.3.1" } }, + "@ant-design/icons": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-4.8.0.tgz", + "integrity": "sha512-T89P2jG2vM7OJ0IfGx2+9FC5sQjtTzRSz+mCHTXkFn/ELZc2YpfStmYHmqzq2Jx55J0F7+O6i5/ZKFSVNWCKNg==", + "requires": { + "@ant-design/colors": "^6.0.0", + "@ant-design/icons-svg": "^4.2.1", + "@babel/runtime": "^7.11.2", + "classnames": "^2.2.6", + "rc-util": "^5.9.4" + }, + "dependencies": { + "@ant-design/colors": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz", + "integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==", + "requires": { + "@ctrl/tinycolor": "^3.4.0" + } + } + } + }, "@ant-design/react-slick": { "version": "0.28.4", "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-0.28.4.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 7136b486aba81..2abbf41d372e6 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -77,7 +77,7 @@ "last 3 edge versions" ], "dependencies": { - "@ant-design/icons": "^4.8.0", + "@ant-design/icons": "^5.0.1", "@babel/runtime-corejs3": "^7.12.5", "@emotion/babel-preset-css-prop": "^11.2.0", "@emotion/cache": "^11.4.0", diff --git a/superset-frontend/packages/superset-ui-chart-controls/package.json b/superset-frontend/packages/superset-ui-chart-controls/package.json index 4773f8c078cbe..a370aa56c9026 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/package.json +++ b/superset-frontend/packages/superset-ui-chart-controls/package.json @@ -30,7 +30,7 @@ "prop-types": "^15.7.2" }, "peerDependencies": { - "@ant-design/icons": "^4.2.2", + "@ant-design/icons": "^5.0.1", "@emotion/react": "^11.4.1", "@superset-ui/core": "*", "@testing-library/dom": "^7.29.4", diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/pivotOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/pivotOperator.ts index 3adec29fecec5..2b8a2bd2f0d59 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/pivotOperator.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/pivotOperator.ts @@ -24,12 +24,16 @@ import { getXAxisLabel, } from '@superset-ui/core'; import { PostProcessingFactory } from './types'; +import { extractExtraMetrics } from './utils'; export const pivotOperator: PostProcessingFactory = ( formData, queryObject, ) => { - const metricLabels = ensureIsArray(queryObject.metrics).map(getMetricLabel); + const metricLabels = [ + ...ensureIsArray(queryObject.metrics), + ...extractExtraMetrics(formData), + ].map(getMetricLabel); const xAxisLabel = getXAxisLabel(formData); const columns = queryObject.series_columns || queryObject.columns; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/sortOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/sortOperator.ts index 0650c8b577851..4d7b5deaf4f75 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/sortOperator.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/sortOperator.ts @@ -26,6 +26,7 @@ import { PostProcessingSort, } from '@superset-ui/core'; import { PostProcessingFactory } from './types'; +import { extractExtraMetrics } from './utils'; export const sortOperator: PostProcessingFactory = ( formData, @@ -34,7 +35,8 @@ export const sortOperator: PostProcessingFactory = ( // the sortOperator only used in the barchart v2 const sortableLabels = [ getXAxisLabel(formData), - ...ensureIsArray(formData.metrics).map(metric => getMetricLabel(metric)), + ...ensureIsArray(formData.metrics).map(getMetricLabel), + ...extractExtraMetrics(formData).map(getMetricLabel), ].filter(Boolean); if ( diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/extractExtraMetrics.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/extractExtraMetrics.ts new file mode 100644 index 0000000000000..74928f836f861 --- /dev/null +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/extractExtraMetrics.ts @@ -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. + */ +import { + getMetricLabel, + QueryFormData, + QueryFormMetric, +} from '@superset-ui/core'; + +export function extractExtraMetrics( + formData: QueryFormData, +): QueryFormMetric[] { + const { groupby, timeseries_limit_metric, x_axis_sort } = formData; + const extra_metrics: QueryFormMetric[] = []; + if ( + !(groupby || []).length && + timeseries_limit_metric && + getMetricLabel(timeseries_limit_metric) === x_axis_sort + ) { + extra_metrics.push(timeseries_limit_metric); + } + return extra_metrics; +} 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 8d65ca1e590fb..67f230dd631b4 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 @@ -20,4 +20,5 @@ export { getMetricOffsetsMap } from './getMetricOffsetsMap'; export { isTimeComparison } from './isTimeComparison'; export { isDerivedSeries } from './isDerivedSeries'; +export { extractExtraMetrics } from './extractExtraMetrics'; export { TIME_COMPARISON_SEPARATOR } from './constants'; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx index 979912e58f1da..8e8f4d8400ca5 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx @@ -23,12 +23,16 @@ import { getColumnLabel, getMetricLabel, isDefined, - isEqualArray, QueryFormColumn, QueryFormMetric, t, } from '@superset-ui/core'; -import { ControlPanelState, ControlState, ControlStateMapping } from '../types'; +import { + ControlPanelState, + ControlState, + ControlStateMapping, + isDataset, +} from '../types'; import { isTemporalColumn } from '../utils'; export const contributionModeControl = { @@ -59,39 +63,42 @@ export const xAxisSortControl = { name: 'x_axis_sort', config: { type: 'XAxisSortControl', - label: t('X-Axis Sort By'), - description: t('Whether to sort descending or ascending on the X-Axis.'), - shouldMapStateToProps: ( - prevState: ControlPanelState, - state: ControlPanelState, - ) => { - const prevOptions = [ - getColumnLabel(prevState?.controls?.x_axis?.value as QueryFormColumn), - ...ensureIsArray(prevState?.controls?.metrics?.value).map(metric => - getMetricLabel(metric as QueryFormMetric), - ), - ]; - const currOptions = [ - getColumnLabel(state?.controls?.x_axis?.value as QueryFormColumn), - ...ensureIsArray(state?.controls?.metrics?.value).map(metric => - getMetricLabel(metric as QueryFormMetric), - ), - ]; - return !isEqualArray(prevOptions, currOptions); - }, - mapStateToProps: ( - { controls }: { controls: ControlStateMapping }, - controlState: ControlState, - ) => { - const choices = [ - getColumnLabel(controls?.x_axis?.value as QueryFormColumn), - ...ensureIsArray(controls?.metrics?.value).map(metric => - getMetricLabel(metric as QueryFormMetric), - ), + label: (state: ControlPanelState) => + state.form_data?.orientation === 'horizontal' + ? t('Y-Axis Sort By') + : t('X-Axis Sort By'), + description: t('Decides which column to sort the base axis by.'), + shouldMapStateToProps: () => true, + mapStateToProps: (state: ControlPanelState, controlState: ControlState) => { + const { controls, datasource } = state; + const dataset = isDataset(datasource) ? datasource : undefined; + const columns = [controls?.x_axis?.value as QueryFormColumn].filter( + Boolean, + ); + const metrics = [ + ...ensureIsArray(controls?.metrics?.value as QueryFormMetric), + controls?.timeseries_limit_metric?.value as QueryFormMetric, ].filter(Boolean); + const options = [ + ...columns.map(column => { + const value = getColumnLabel(column); + return { + value, + label: dataset?.verbose_map?.[value] || value, + }; + }), + ...metrics.map(metric => { + const value = getMetricLabel(metric); + return { + value, + label: dataset?.verbose_map?.[value] || value, + }; + }), + ]; + const shouldReset = !( typeof controlState.value === 'string' && - choices.includes(controlState.value) && + options.map(option => option.value).includes(controlState.value) && !isTemporalColumn( getColumnLabel(controls?.x_axis?.value as QueryFormColumn), controls?.datasource?.datasource, @@ -100,10 +107,7 @@ export const xAxisSortControl = { return { shouldReset, - options: choices.map(entry => ({ - value: entry, - label: entry, - })), + options, }; }, visibility: xAxisSortVisibility, @@ -114,9 +118,12 @@ export const xAxisSortAscControl = { name: 'x_axis_sort_asc', config: { type: 'CheckboxControl', - label: t('X-Axis Sort Ascending'), + label: (state: ControlPanelState) => + state.form_data?.orientation === 'horizontal' + ? t('Y-Axis Sort Ascending') + : t('X-Axis Sort Ascending'), default: true, - description: t('Whether to sort descending or ascending on the X-Axis.'), + description: t('Whether to sort ascending or descending on the base Axis.'), visibility: xAxisSortVisibility, }, }; diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/operators/pivotOperator.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/operators/pivotOperator.test.ts index 2a527a04fe544..6101fc19e54c6 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/operators/pivotOperator.test.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/test/operators/pivotOperator.test.ts @@ -185,3 +185,33 @@ test('pivot by adhoc x_axis', () => { }, }); }); + +test('pivot by x_axis with extra metrics', () => { + expect( + pivotOperator( + { + ...formData, + x_axis: 'foo', + x_axis_sort: 'bar', + groupby: [], + timeseries_limit_metric: 'bar', + }, + { + ...queryObject, + series_columns: [], + }, + ), + ).toEqual({ + operation: 'pivot', + options: { + index: ['foo'], + columns: [], + aggregates: { + 'count(*)': { operator: 'mean' }, + 'sum(val)': { operator: 'mean' }, + bar: { operator: 'mean' }, + }, + drop_missing_columns: false, + }, + }); +}); diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/operators/sortOperator.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/operators/sortOperator.test.ts index 750d726c902af..41d44b50a8383 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/operators/sortOperator.test.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/test/operators/sortOperator.test.ts @@ -146,3 +146,28 @@ test('should sort by axis', () => { }, }); }); + +test('should sort by extra metric', () => { + Object.defineProperty(supersetCoreModule, 'hasGenericChartAxes', { + value: true, + }); + expect( + sortOperator( + { + ...formData, + x_axis_sort: 'my_limit_metric', + x_axis_sort_asc: true, + x_axis: 'Categorical Column', + groupby: [], + timeseries_limit_metric: 'my_limit_metric', + }, + queryObject, + ), + ).toEqual({ + operation: 'sort', + options: { + by: 'my_limit_metric', + ascending: true, + }, + }); +}); diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/operators/utils/extractExtraMetrics.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/operators/utils/extractExtraMetrics.test.ts new file mode 100644 index 0000000000000..89f4c11181192 --- /dev/null +++ b/superset-frontend/packages/superset-ui-chart-controls/test/operators/utils/extractExtraMetrics.test.ts @@ -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 { QueryFormData, QueryFormMetric } from '@superset-ui/core'; +import { extractExtraMetrics } from '@superset-ui/chart-controls'; + +const baseFormData: QueryFormData = { + datasource: 'dummy', + viz_type: 'table', + metrics: ['a', 'b'], + columns: ['foo', 'bar'], + limit: 100, + metrics_b: ['c', 'd'], + columns_b: ['hello', 'world'], + limit_b: 200, +}; + +const metric: QueryFormMetric = { + expressionType: 'SQL', + sqlExpression: 'case when 1 then 1 else 2 end', + label: 'foo', +}; + +test('returns empty array if relevant controls missing', () => { + expect( + extractExtraMetrics({ + ...baseFormData, + }), + ).toEqual([]); +}); + +test('returns empty array if x_axis_sort is not same as timeseries_limit_metric', () => { + expect( + extractExtraMetrics({ + ...baseFormData, + timeseries_limit_metric: 'foo', + x_axis_sort: 'bar', + }), + ).toEqual([]); +}); + +test('returns correct column if sort columns match', () => { + expect( + extractExtraMetrics({ + ...baseFormData, + timeseries_limit_metric: 'foo', + x_axis_sort: 'foo', + }), + ).toEqual(['foo']); +}); + +test('handles adhoc metrics correctly', () => { + expect( + extractExtraMetrics({ + ...baseFormData, + timeseries_limit_metric: metric, + x_axis_sort: 'foo', + }), + ).toEqual([metric]); + + expect( + extractExtraMetrics({ + ...baseFormData, + timeseries_limit_metric: metric, + x_axis_sort: 'bar', + }), + ).toEqual([]); +}); + +test('returns empty array if groupby populated', () => { + expect( + extractExtraMetrics({ + ...baseFormData, + groupby: ['bar'], + timeseries_limit_metric: 'foo', + x_axis_sort: 'foo', + }), + ).toEqual([]); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/components/SafeMarkdown.tsx b/superset-frontend/packages/superset-ui-core/src/components/SafeMarkdown.tsx index 4db48d426533d..8be73295b4971 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/SafeMarkdown.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/SafeMarkdown.tsx @@ -20,7 +20,7 @@ import React, { useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; import rehypeRaw from 'rehype-raw'; -import { merge } from 'lodash'; +import { mergeWith, isArray } from 'lodash'; import { FeatureFlag, isFeatureEnabled } from '../utils'; interface SafeMarkdownProps { @@ -29,6 +29,15 @@ interface SafeMarkdownProps { htmlSchemaOverrides?: typeof defaultSchema; } +export function getOverrideHtmlSchema( + originalSchema: typeof defaultSchema, + htmlSchemaOverrides: SafeMarkdownProps['htmlSchemaOverrides'], +) { + return mergeWith(originalSchema, htmlSchemaOverrides, (objValue, srcValue) => + isArray(objValue) ? objValue.concat(srcValue) : undefined, + ); +} + function SafeMarkdown({ source, htmlSanitization = true, @@ -42,7 +51,10 @@ function SafeMarkdown({ if (displayHtml && !escapeHtml) { rehypePlugins.push(rehypeRaw); if (htmlSanitization) { - const schema = merge(defaultSchema, htmlSchemaOverrides); + const schema = getOverrideHtmlSchema( + defaultSchema, + htmlSchemaOverrides, + ); rehypePlugins.push([rehypeSanitize, schema]); } } diff --git a/superset-frontend/packages/superset-ui-core/src/time-format/index.ts b/superset-frontend/packages/superset-ui-core/src/time-format/index.ts index 48ac1a6803db5..b086effd246a9 100644 --- a/superset-frontend/packages/superset-ui-core/src/time-format/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/time-format/index.ts @@ -36,4 +36,6 @@ export { default as smartDateFormatter } from './formatters/smartDate'; export { default as smartDateDetailedFormatter } from './formatters/smartDateDetailed'; export { default as smartDateVerboseFormatter } from './formatters/smartDateVerbose'; +export { default as normalizeTimestamp } from './utils/normalizeTimestamp'; + export * from './types'; diff --git a/superset-frontend/src/dashboard/components/CrossFilterScopingModal/utils/utils.test.ts b/superset-frontend/packages/superset-ui-core/src/time-format/utils/normalizeTimestamp.ts similarity index 67% rename from superset-frontend/src/dashboard/components/CrossFilterScopingModal/utils/utils.test.ts rename to superset-frontend/packages/superset-ui-core/src/time-format/utils/normalizeTimestamp.ts index edc4fde937d71..0e49aee7ea754 100644 --- a/superset-frontend/src/dashboard/components/CrossFilterScopingModal/utils/utils.test.ts +++ b/superset-frontend/packages/superset-ui-core/src/time-format/utils/normalizeTimestamp.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,18 +17,12 @@ * under the License. */ -import { setCrossFilterFieldValues } from '.'; +const TS_REGEX = /(\d{4}-\d{2}-\d{2})[\sT](\d{2}:\d{2}:\d{2}\.?\d*).*/; -test('setValues', () => { - const from = { setFieldsValue: jest.fn() }; - const values = { - val01: 'val01', - val02: 'val02', - val03: 'val03', - val04: 'val04', - }; - setCrossFilterFieldValues(from as any, values); - - expect(from.setFieldsValue).toBeCalledTimes(1); - expect(from.setFieldsValue).toBeCalledWith(values); -}); +export default function normalizeTimestamp(value: string): string { + const match = value.match(TS_REGEX); + if (match) { + return `${match[1]}T${match[2]}Z`; + } + return value; +} diff --git a/superset-frontend/src/dashboard/components/CrossFilterScopingModal/utils/index.ts b/superset-frontend/packages/superset-ui-core/src/utils/getSelectedText.ts similarity index 76% rename from superset-frontend/src/dashboard/components/CrossFilterScopingModal/utils/index.ts rename to superset-frontend/packages/superset-ui-core/src/utils/getSelectedText.ts index e3eaf3e0c868f..d3671d875c33b 100644 --- a/superset-frontend/src/dashboard/components/CrossFilterScopingModal/utils/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/getSelectedText.ts @@ -16,14 +16,4 @@ * specific language governing permissions and limitations * under the License. */ -import { FormInstance } from 'src/components'; - -// eslint-disable-next-line import/prefer-default-export -export const setCrossFilterFieldValues = ( - form: FormInstance, - values: object, -) => { - form.setFieldsValue({ - ...values, - }); -}; +export const getSelectedText = () => window.getSelection()?.toString(); diff --git a/superset-frontend/packages/superset-ui-core/src/utils/index.ts b/superset-frontend/packages/superset-ui-core/src/utils/index.ts index 19c5ed586145a..4efc3dedb65af 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/index.ts @@ -27,6 +27,7 @@ export { default as promiseTimeout } from './promiseTimeout'; export { default as logging } from './logging'; export { default as removeDuplicates } from './removeDuplicates'; export { lruCache } from './lruCache'; +export { getSelectedText } from './getSelectedText'; export * from './featureFlags'; export * from './random'; export * from './typedMemo'; diff --git a/superset-frontend/packages/superset-ui-core/test/components/SafeMarkdown.test.ts b/superset-frontend/packages/superset-ui-core/test/components/SafeMarkdown.test.ts new file mode 100644 index 0000000000000..4b4c826923bc5 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/test/components/SafeMarkdown.test.ts @@ -0,0 +1,39 @@ +/** + * 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 { getOverrideHtmlSchema } from '../../src/components/SafeMarkdown'; + +describe('getOverrideHtmlSchema', () => { + it('should append the override items', () => { + const original = { + attributes: { + '*': ['size'], + }, + clobberPrefix: 'original-prefix', + tagNames: ['h1', 'h2', 'h3'], + }; + const result = getOverrideHtmlSchema(original, { + attributes: { '*': ['src'], h1: ['style'] }, + clobberPrefix: 'custom-prefix', + tagNames: ['iframe'], + }); + expect(result.clobberPrefix).toEqual('custom-prefix'); + expect(result.attributes).toEqual({ '*': ['size', 'src'], h1: ['style'] }); + expect(result.tagNames).toEqual(['h1', 'h2', 'h3', 'iframe']); + }); +}); diff --git a/superset-frontend/packages/superset-ui-core/test/time-format/utils/normalizeTimestamp.test.ts b/superset-frontend/packages/superset-ui-core/test/time-format/utils/normalizeTimestamp.test.ts new file mode 100644 index 0000000000000..6ccdcb574deb8 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/test/time-format/utils/normalizeTimestamp.test.ts @@ -0,0 +1,43 @@ +/* + * 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 normalizeTimestamp from '../../../src/time-format/utils/normalizeTimestamp'; + +test('normalizeTimestamp should normalize typical timestamps', () => { + expect(normalizeTimestamp('2023-03-11 08:26:52.695 UTC')).toEqual( + '2023-03-11T08:26:52.695Z', + ); + expect(normalizeTimestamp('2023-03-11 08:26:52.695 Europe/Helsinki')).toEqual( + '2023-03-11T08:26:52.695Z', + ); + expect(normalizeTimestamp('2023-03-11T08:26:52.695 UTC')).toEqual( + '2023-03-11T08:26:52.695Z', + ); + expect(normalizeTimestamp('2023-03-11T08:26:52.695')).toEqual( + '2023-03-11T08:26:52.695Z', + ); + expect(normalizeTimestamp('2023-03-11 08:26:52')).toEqual( + '2023-03-11T08:26:52Z', + ); +}); + +test('normalizeTimestamp should return unmatched timestamps as-is', () => { + expect(normalizeTimestamp('abcd')).toEqual('abcd'); + expect(normalizeTimestamp('03/11/2023')).toEqual('03/11/2023'); +}); diff --git a/superset-frontend/packages/superset-ui-core/test/utils/getSelectedText.test.ts b/superset-frontend/packages/superset-ui-core/test/utils/getSelectedText.test.ts new file mode 100644 index 0000000000000..75682e9e747c2 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/test/utils/getSelectedText.test.ts @@ -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. + */ +import { getSelectedText } from '@superset-ui/core'; + +test('Returns null if Selection object is null', () => { + jest.spyOn(window, 'getSelection').mockImplementationOnce(() => null); + expect(getSelectedText()).toEqual(undefined); + jest.restoreAllMocks(); +}); + +test('Returns selection text if Selection object is not null', () => { + jest + .spyOn(window, 'getSelection') + // @ts-ignore + .mockImplementationOnce(() => ({ toString: () => 'test string' })); + expect(getSelectedText()).toEqual('test string'); + jest.restoreAllMocks(); +}); 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 ad021f92b9189..69a8020657b8a 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts @@ -19,24 +19,25 @@ import { buildQueryContext, ensureIsArray, + getXAxisColumn, + isXAxisSet, normalizeOrderBy, PostProcessingPivot, QueryFormData, - getXAxisColumn, - isXAxisSet, } from '@superset-ui/core'; import { - rollingWindowOperator, - timeCompareOperator, + contributionOperator, + extractExtraMetrics, + flattenOperator, isTimeComparison, pivotOperator, - resampleOperator, - renameOperator, - contributionOperator, prophetOperator, - timeComparePivotOperator, - flattenOperator, + renameOperator, + resampleOperator, + rollingWindowOperator, sortOperator, + timeComparePivotOperator, + timeCompareOperator, } from '@superset-ui/chart-controls'; export default function buildQuery(formData: QueryFormData) { @@ -62,6 +63,9 @@ export default function buildQuery(formData: QueryFormData) { 2015-03-01 318.0 0.0 */ + // only add series limit metric if it's explicitly needed e.g. for sorting + const extra_metrics = extractExtraMetrics(formData); + const pivotOperatorInRuntime: PostProcessingPivot = isTimeComparison( formData, baseQueryObject, @@ -69,15 +73,16 @@ export default function buildQuery(formData: QueryFormData) { ? timeComparePivotOperator(formData, baseQueryObject) : pivotOperator(formData, baseQueryObject); + const columns = [ + ...(isXAxisSet(formData) ? ensureIsArray(getXAxisColumn(formData)) : []), + ...ensureIsArray(groupby), + ]; + return [ { ...baseQueryObject, - columns: [ - ...(isXAxisSet(formData) - ? ensureIsArray(getXAxisColumn(formData)) - : []), - ...ensureIsArray(groupby), - ], + metrics: [...(baseQueryObject.metrics || []), ...extra_metrics], + columns, series_columns: groupby, ...(isXAxisSet(formData) ? {} : { is_timeseries: true }), // todo: move `normalizeOrderBy to extractQueryFields` 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 eadded44a9ac5..a853c4b869d16 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -19,21 +19,25 @@ /* eslint-disable camelcase */ import { AnnotationLayer, + AxisType, CategoricalColorNamespace, GenericDataType, + getMetricLabel, getNumberFormatter, + getXAxisLabel, + isDefined, isEventAnnotationLayer, isFormulaAnnotationLayer, isIntervalAnnotationLayer, + isPhysicalColumn, isTimeseriesAnnotationLayer, - TimeseriesChartDataResponseResult, t, - AxisType, - getXAxisLabel, - isPhysicalColumn, - isDefined, + TimeseriesChartDataResponseResult, } from '@superset-ui/core'; -import { isDerivedSeries } from '@superset-ui/chart-controls'; +import { + extractExtraMetrics, + isDerivedSeries, +} from '@superset-ui/chart-controls'; import { EChartsCoreOption, SeriesOption } from 'echarts'; import { ZRLineType } from 'echarts/types/src/util/types'; import { @@ -103,8 +107,9 @@ export default function transformProps( } = chartProps; const { verboseMap = {} } = datasource; const [queryData] = queriesData; - const { data = [], label_map: labelMap } = + const { data = [], label_map = {} } = queryData as TimeseriesChartDataResponseResult; + const dataTypes = getColtypesMapping(queryData); const annotationData = getAnnotationData(chartProps); @@ -114,42 +119,54 @@ export default function transformProps( colorScheme, contributionMode, forecastEnabled, + groupby, legendOrientation, legendType, legendMargin, logAxis, markerEnabled, markerSize, - opacity, minorSplitLine, + onlyTotal, + opacity, + orientation, + percentageThreshold, + richTooltip, seriesType, showLegend, + showValue, + sliceId, + timeGrainSqla, + timeCompare, stack, - truncateYAxis, - yAxisFormat, - xAxisTimeFormat, - yAxisBounds, tooltipTimeFormat, tooltipSortByMetric, - zoomable, - richTooltip, + truncateYAxis, xAxis: xAxisOrig, xAxisLabelRotation, - groupby, - showValue, - onlyTotal, - percentageThreshold, + xAxisTimeFormat, xAxisTitle, - yAxisTitle, xAxisTitleMargin, + yAxisBounds, + yAxisFormat, + yAxisTitle, yAxisTitleMargin, yAxisTitlePosition, - sliceId, - timeGrainSqla, - orientation, + zoomable, }: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData }; const refs: Refs = {}; + const labelMap = Object.entries(label_map).reduce((acc, entry) => { + if ( + entry[1].length > groupby.length && + Array.isArray(timeCompare) && + timeCompare.includes(entry[1][0]) + ) { + entry[1].shift(); + } + return { ...acc, [entry[0]]: entry[1] }; + }, {}); + const colorScale = CategoricalColorNamespace.getScale(colorScheme as string); const rebasedData = rebaseForecastDatum(data, verboseMap); let xAxisLabel = getXAxisLabel(chartProps.rawFormData) as string; @@ -168,9 +185,14 @@ export default function transformProps( xAxisCol: xAxisLabel, }, ); + const extraMetricLabels = extractExtraMetrics(chartProps.rawFormData).map( + getMetricLabel, + ); + const rawSeries = extractSeries(rebasedData, { fillNeighborValue: stack && !forecastEnabled ? 0 : undefined, xAxis: xAxisLabel, + extraMetricLabels, removeNulls: seriesType === EchartsTimeseriesSeriesType.Scatter, stack, totalStackedValues, @@ -370,6 +392,7 @@ export default function transformProps( if (isHorizontal) { [xAxis, yAxis] = [yAxis, xAxis]; [padding.bottom, padding.left] = [padding.left, padding.bottom]; + yAxis.inverse = true; } const echartOptions: EChartsCoreOption = { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts index 56527ebd63bf9..bca13a0584660 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts @@ -71,6 +71,7 @@ export type EchartsTimeseriesFormData = QueryFormData & { rowLimit: number; seriesType: EchartsTimeseriesSeriesType; stack: StackType; + timeCompare?: string[]; tooltipTimeFormat?: string; truncateYAxis: boolean; yAxisFormat?: string; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts index c1b61233b6c62..649dedd68051e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -113,6 +113,7 @@ export function extractSeries( opts: { fillNeighborValue?: number; xAxis?: string; + extraMetricLabels?: string[]; removeNulls?: boolean; stack?: StackType; totalStackedValues?: number[]; @@ -122,6 +123,7 @@ export function extractSeries( const { fillNeighborValue, xAxis = DTTM_ALIAS, + extraMetricLabels = [], removeNulls = false, stack = false, totalStackedValues = [], @@ -135,6 +137,7 @@ export function extractSeries( return Object.keys(rows[0]) .filter(key => key !== xAxis && key !== DTTM_ALIAS) + .filter(key => !extraMetricLabels.includes(key)) .map(key => ({ id: key, name: key, diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts index df48354a81586..63ca50449ed09 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts @@ -87,6 +87,44 @@ describe('EchartsTimeseries transformProps', () => { ); }); + it('should transform chart props for horizontal viz', () => { + const chartProps = new ChartProps({ + ...chartPropsConfig, + formData: { + ...formData, + orientation: 'horizontal', + }, + }); + expect(transformProps(chartProps as EchartsTimeseriesChartProps)).toEqual( + expect.objectContaining({ + width: 800, + height: 600, + echartOptions: expect.objectContaining({ + legend: expect.objectContaining({ + data: ['San Francisco', 'New York'], + }), + series: expect.arrayContaining([ + expect.objectContaining({ + data: [ + [1, 599616000000], + [3, 599916000000], + ], + name: 'San Francisco', + }), + expect.objectContaining({ + data: [ + [2, 599616000000], + [4, 599916000000], + ], + name: 'New York', + }), + ]), + yAxis: expect.objectContaining({ inverse: true }), + }), + }), + ); + }); + it('should add a formula annotation to viz', () => { const formula: FormulaAnnotationLayer = { name: 'My Formula', @@ -459,4 +497,35 @@ describe('Does transformProps transform series correctly', () => { }); }); }); + + it('should remove time shift labels from label_map', () => { + const updatedChartPropsConfig = { + ...chartPropsConfig, + formData: { + ...formData, + timeCompare: ['1 year ago'], + }, + queriesData: [ + { + ...queriesData[0], + label_map: { + '1 year ago, foo1, bar1': ['1 year ago', 'foo1', 'bar1'], + '1 year ago, foo2, bar2': ['1 year ago', 'foo2', 'bar2'], + 'foo1, bar1': ['foo1', 'bar1'], + 'foo2, bar2': ['foo2', 'bar2'], + }, + }, + ], + }; + const chartProps = new ChartProps(updatedChartPropsConfig); + const transformedProps = transformProps( + chartProps as EchartsTimeseriesChartProps, + ); + expect(transformedProps.labelMap).toEqual({ + '1 year ago, foo1, bar1': ['foo1', 'bar1'], + '1 year ago, foo2, bar2': ['foo2', 'bar2'], + 'foo1, bar1': ['foo1', 'bar1'], + 'foo2, bar2': ['foo2', 'bar2'], + }); + }); }); diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/package.json b/superset-frontend/plugins/plugin-chart-pivot-table/package.json index bed12a2e7250e..9f803467bd4b8 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/package.json +++ b/superset-frontend/plugins/plugin-chart-pivot-table/package.json @@ -29,7 +29,7 @@ "peerDependencies": { "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", - "@ant-design/icons": "^4.2.2", + "@ant-design/icons": "^5.0.1", "react": "^16.13.1", "react-dom": "^16.13.1", "prop-types": "*", diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx index 84a64adfc7745..53e98fd31df0a 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx @@ -30,6 +30,7 @@ import { isAdhocColumn, BinaryQueryObjectFilterClause, t, + getSelectedText, } from '@superset-ui/core'; import { PivotTable, sortAs, aggregatorTemplates } from './react-pivottable'; import { @@ -356,6 +357,11 @@ export default function PivotTableChart(props: PivotTableProps) { return; } + // allow selecting text in a cell + if (getSelectedText()) { + return; + } + const isActiveFilterValue = (key: string, val: DataRecordValue) => !!selectedFilters && selectedFilters[key]?.includes(val); diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx index 9843a4ae8b17b..ac02a10137b37 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx @@ -41,6 +41,7 @@ import { DTTM_ALIAS, ensureIsArray, GenericDataType, + getSelectedText, getTimeFormatterForGranularity, BinaryQueryObjectFilterClause, styled, @@ -493,7 +494,12 @@ export default function TableChart( title: typeof value === 'number' ? String(value) : undefined, onClick: emitCrossFilters && !valueRange && !isMetric - ? () => toggleFilter(key, value) + ? () => { + // allow selecting text in a cell + if (!getSelectedText()) { + toggleFilter(key, value); + } + } : undefined, onContextMenu: (e: MouseEvent) => { if (handleContextMenu) { diff --git a/superset-frontend/plugins/plugin-chart-table/src/utils/DateWithFormatter.ts b/superset-frontend/plugins/plugin-chart-table/src/utils/DateWithFormatter.ts index eef513bca0406..c92c2ca1abb3a 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/utils/DateWithFormatter.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/utils/DateWithFormatter.ts @@ -16,9 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -import { DataRecordValue, TimeFormatFunction } from '@superset-ui/core'; - -const REGEXP_TIMESTAMP_NO_TIMEZONE = /T(\d{2}:){2}\d{2}$/; +import { + DataRecordValue, + normalizeTimestamp, + TimeFormatFunction, +} from '@superset-ui/core'; /** * Extended Date object with a custom formatter, and retains the original input @@ -31,19 +33,12 @@ export default class DateWithFormatter extends Date { constructor( input: DataRecordValue, - { - formatter = String, - forceUTC = true, - }: { formatter?: TimeFormatFunction; forceUTC?: boolean } = {}, + { formatter = String }: { formatter?: TimeFormatFunction } = {}, ) { let value = input; // assuming timestamps without a timezone is in UTC time - if ( - forceUTC && - typeof value === 'string' && - REGEXP_TIMESTAMP_NO_TIMEZONE.test(value) - ) { - value = `${value}Z`; + if (typeof value === 'string') { + value = normalizeTimestamp(value); } super(value as string); diff --git a/superset-frontend/spec/fixtures/mockDashboardState.js b/superset-frontend/spec/fixtures/mockDashboardState.js index 0895ccf386955..737e38aef59e0 100644 --- a/superset-frontend/spec/fixtures/mockDashboardState.js +++ b/superset-frontend/spec/fixtures/mockDashboardState.js @@ -113,6 +113,6 @@ export const overwriteConfirmMetadata = { slug: null, owners: [], json_metadata: - '{"timed_refresh_immune_slices":[],"expanded_slices":{},"refresh_frequency":0,"default_filters":"{}","color_scheme":"supersetColors","label_colors":{"0":"#FCC700","1":"#A868B7","15":"#3CCCCB","30":"#A38F79","45":"#8FD3E4","age":"#1FA8C9","Yes,":"#1FA8C9","Female":"#454E7C","Prefer":"#5AC189","No,":"#FF7F44","Male":"#666666","Prefer not to say":"#E04355","Ph.D.":"#FCC700","associate\'s degree":"#A868B7","bachelor\'s degree":"#3CCCCB","high school diploma or equivalent (GED)":"#A38F79","master\'s degree (non-professional)":"#8FD3E4","no high school (secondary school)":"#A1A6BD","professional degree (MBA, MD, JD, etc.)":"#ACE1C4","some college credit, no degree":"#FEC0A1","some high school":"#B2B2B2","trade, technical, or vocational training":"#EFA1AA","No, not an ethnic minority":"#1FA8C9","Yes, an ethnic minority":"#454E7C","":"#5AC189","Yes":"#FF7F44","No":"#666666","last_yr_income":"#E04355","More":"#A1A6BD","Less":"#ACE1C4","I":"#FEC0A1","expected_earn":"#B2B2B2","Yes: Willing To":"#EFA1AA","No: Not Willing to":"#FDE380","No Answer":"#D3B3DA","In an Office (with Other Developers)":"#9EE5E5","No Preference":"#D1C6BC","From Home":"#1FA8C9"},"show_native_filters":true,"color_scheme_domain":["#1FA8C9","#454E7C","#5AC189","#FF7F44","#666666","#E04355","#FCC700","#A868B7","#3CCCCB","#A38F79","#8FD3E4","#A1A6BD","#ACE1C4","#FEC0A1","#B2B2B2","#EFA1AA","#FDE380","#D3B3DA","#9EE5E5","#D1C6BC"],"shared_label_colors":{"Male":"#5ac19e","Female":"#1f86c9","":"#5AC189","Prefer not to say":"#47457c","No Answer":"#e05043","Yes, an ethnic minority":"#666666","No, not an ethnic minority":"#ffa444","age":"#1FA8C9"},"cross_filters_enabled":false,"filter_scopes":{},"chart_configuration":{},"positions":{}}', + '{"timed_refresh_immune_slices":[],"expanded_slices":{},"refresh_frequency":0,"default_filters":"{}","color_scheme":"supersetColors","label_colors":{"0":"#FCC700","1":"#A868B7","15":"#3CCCCB","30":"#A38F79","45":"#8FD3E4","age":"#1FA8C9","Yes,":"#1FA8C9","Female":"#454E7C","Prefer":"#5AC189","No,":"#FF7F44","Male":"#666666","Prefer not to say":"#E04355","Ph.D.":"#FCC700","associate\'s degree":"#A868B7","bachelor\'s degree":"#3CCCCB","high school diploma or equivalent (GED)":"#A38F79","master\'s degree (non-professional)":"#8FD3E4","no high school (secondary school)":"#A1A6BD","professional degree (MBA, MD, JD, etc.)":"#ACE1C4","some college credit, no degree":"#FEC0A1","some high school":"#B2B2B2","trade, technical, or vocational training":"#EFA1AA","No, not an ethnic minority":"#1FA8C9","Yes, an ethnic minority":"#454E7C","":"#5AC189","Yes":"#FF7F44","No":"#666666","last_yr_income":"#E04355","More":"#A1A6BD","Less":"#ACE1C4","I":"#FEC0A1","expected_earn":"#B2B2B2","Yes: Willing To":"#EFA1AA","No: Not Willing to":"#FDE380","No Answer":"#D3B3DA","In an Office (with Other Developers)":"#9EE5E5","No Preference":"#D1C6BC","From Home":"#1FA8C9"},"color_scheme_domain":["#1FA8C9","#454E7C","#5AC189","#FF7F44","#666666","#E04355","#FCC700","#A868B7","#3CCCCB","#A38F79","#8FD3E4","#A1A6BD","#ACE1C4","#FEC0A1","#B2B2B2","#EFA1AA","#FDE380","#D3B3DA","#9EE5E5","#D1C6BC"],"shared_label_colors":{"Male":"#5ac19e","Female":"#1f86c9","":"#5AC189","Prefer not to say":"#47457c","No Answer":"#e05043","Yes, an ethnic minority":"#666666","No, not an ethnic minority":"#ffa444","age":"#1FA8C9"},"cross_filters_enabled":false,"filter_scopes":{},"chart_configuration":{},"positions":{}}', }, }; diff --git a/superset-frontend/src/assets/images/icons/filter.svg b/superset-frontend/src/assets/images/icons/filter.svg index ec7e8815d1d4b..6ce4a8f1e6396 100644 --- a/superset-frontend/src/assets/images/icons/filter.svg +++ b/superset-frontend/src/assets/images/icons/filter.svg @@ -16,6 +16,7 @@ KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> - - + + + diff --git a/superset-frontend/src/constants.ts b/superset-frontend/src/constants.ts index 26282ed5fcd85..40d0b22aec8b1 100644 --- a/superset-frontend/src/constants.ts +++ b/superset-frontend/src/constants.ts @@ -51,10 +51,6 @@ export const URL_PARAMS = { name: 'filter_set', type: 'string', }, - showFilters: { - name: 'show_filters', - type: 'boolean', - }, expandFilters: { name: 'expand_filters', type: 'boolean', diff --git a/superset-frontend/src/dashboard/actions/dashboardState.js b/superset-frontend/src/dashboard/actions/dashboardState.js index 8db1a500eee32..058b3700bf44f 100644 --- a/superset-frontend/src/dashboard/actions/dashboardState.js +++ b/superset-frontend/src/dashboard/actions/dashboardState.js @@ -36,7 +36,10 @@ import { SAVE_TYPE_OVERWRITE, SAVE_TYPE_OVERWRITE_CONFIRMED, } from 'src/dashboard/util/constants'; -import { isCrossFiltersEnabled } from 'src/dashboard/util/crossFilters'; +import { + getCrossFiltersConfiguration, + isCrossFiltersEnabled, +} from 'src/dashboard/util/crossFilters'; import { addSuccessToast, addWarningToast, @@ -277,25 +280,17 @@ export function saveDashboardRequest(data, id, saveType) { const handleChartConfiguration = () => { const { + dashboardLayout, + charts, dashboardInfo: { metadata: { chart_configuration = {} }, }, } = getState(); - const chartConfiguration = Object.values(chart_configuration).reduce( - (prev, next) => { - // If chart removed from dashboard - remove it from metadata - if ( - Object.values(layout).find( - layoutItem => layoutItem?.meta?.chartId === next.id, - ) - ) { - return { ...prev, [next.id]: next }; - } - return prev; - }, - {}, + return getCrossFiltersConfiguration( + dashboardLayout.present, + chart_configuration, + charts, ); - return chartConfiguration; }; const onCopySuccess = response => { diff --git a/superset-frontend/src/dashboard/actions/hydrate.js b/superset-frontend/src/dashboard/actions/hydrate.js index 2b550e0783f7d..e0b17b737dc67 100644 --- a/superset-frontend/src/dashboard/actions/hydrate.js +++ b/superset-frontend/src/dashboard/actions/hydrate.js @@ -16,9 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -/* eslint-disable camelcase */ -import { Behavior, getChartMetadataRegistry } from '@superset-ui/core'; - import { chart } from 'src/components/Chart/chartReducer'; import { initSliceEntities } from 'src/dashboard/reducers/sliceEntities'; import { getInitialState as getInitialNativeFilterState } from 'src/dashboard/reducers/nativeFilters'; @@ -26,7 +23,10 @@ import { applyDefaultFormData } from 'src/explore/store'; import { buildActiveFilters } from 'src/dashboard/util/activeDashboardFilters'; import { findPermission } from 'src/utils/findPermission'; import { canUserEditDashboard } from 'src/dashboard/util/permissionUtils'; -import { isCrossFiltersEnabled } from 'src/dashboard/util/crossFilters'; +import { + getCrossFiltersConfiguration, + isCrossFiltersEnabled, +} from 'src/dashboard/util/crossFilters'; import { DASHBOARD_FILTER_SCOPE_GLOBAL, dashboardFilter, @@ -54,7 +54,6 @@ import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; import { FeatureFlag, isFeatureEnabled } from '../../featureFlags'; import extractUrlParams from '../util/extractUrlParams'; import { updateColorSchema } from './dashboardInfo'; -import { getChartIdsInFilterScope } from '../util/getChartIdsInFilterScope'; import updateComponentParentsList from '../util/updateComponentParentsList'; import { FilterBarOrientation } from '../types'; @@ -124,7 +123,7 @@ export const hydrateDashboard = charts.forEach(slice => { const key = slice.slice_id; - const form_data = { + const formData = { ...slice.form_data, url_params: { ...slice.form_data.url_params, @@ -134,7 +133,7 @@ export const hydrateDashboard = chartQueries[key] = { ...chart, id: key, - form_data: applyDefaultFormData(form_data), + form_data: applyDefaultFormData(formData), }; slices[key] = { @@ -306,59 +305,13 @@ export const hydrateDashboard = const nativeFilters = getInitialNativeFilterState({ filterConfig: metadata?.native_filter_configuration || [], }); - metadata.show_native_filters = isFeatureEnabled( - FeatureFlag.DASHBOARD_NATIVE_FILTERS, - ); if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) { - // If user just added cross filter to dashboard it's not saving it scope on server, - // so we tweak it until user will update scope and will save it in server - Object.values(dashboardLayout.present).forEach(layoutItem => { - const chartId = layoutItem.meta?.chartId; - const behaviors = - ( - getChartMetadataRegistry().get( - chartQueries[chartId]?.form_data?.viz_type, - ) ?? {} - )?.behaviors ?? []; - - if (!metadata.chart_configuration) { - metadata.chart_configuration = {}; - } - if (behaviors.includes(Behavior.INTERACTIVE_CHART)) { - if (!metadata.chart_configuration[chartId]) { - metadata.chart_configuration[chartId] = { - id: chartId, - crossFilters: { - scope: { - rootPath: [DASHBOARD_ROOT_ID], - excluded: [chartId], // By default it doesn't affects itself - }, - }, - }; - } - metadata.chart_configuration[chartId].crossFilters.chartsInScope = - getChartIdsInFilterScope( - metadata.chart_configuration[chartId].crossFilters.scope, - chartQueries, - dashboardLayout.present, - ); - } - if ( - behaviors.includes(Behavior.INTERACTIVE_CHART) && - !metadata.chart_configuration[chartId] - ) { - metadata.chart_configuration[chartId] = { - id: chartId, - crossFilters: { - scope: { - rootPath: [DASHBOARD_ROOT_ID], - excluded: [chartId], // By default it doesn't affects itself - }, - }, - }; - } - }); + metadata.chart_configuration = getCrossFiltersConfiguration( + dashboardLayout.present, + metadata.chart_configuration, + chartQueries, + ); } const { roles } = user; diff --git a/superset-frontend/src/dashboard/actions/sliceEntities.js b/superset-frontend/src/dashboard/actions/sliceEntities.js index 53a681de818a6..13d7ce45bfc04 100644 --- a/superset-frontend/src/dashboard/actions/sliceEntities.js +++ b/superset-frontend/src/dashboard/actions/sliceEntities.js @@ -22,6 +22,7 @@ import rison from 'rison'; import { addDangerToast } from 'src/components/MessageToasts/actions'; import { getClientErrorObject } from 'src/utils/getClientErrorObject'; +import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; export const SET_ALL_SLICES = 'SET_ALL_SLICES'; const FETCH_SLICES_PAGE_SIZE = 200; @@ -55,6 +56,14 @@ export function fetchSlices( ? [{ col: 'slice_name', opr: 'chart_all_text', value: filter_value }] : []; + if (isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS)) { + additional_filters.push({ + col: 'viz_type', + opr: 'neq', + value: 'filter_box', + }); + } + const cloneSlices = { ...slices }; return SupersetClient.get({ diff --git a/superset-frontend/src/dashboard/components/CrossFilterScopingModal/CrossFilterScopingForm/CrossFilterScopingForm.test.tsx b/superset-frontend/src/dashboard/components/CrossFilterScopingModal/CrossFilterScopingForm/CrossFilterScopingForm.test.tsx deleted file mode 100644 index f1093e2a9e256..0000000000000 --- a/superset-frontend/src/dashboard/components/CrossFilterScopingModal/CrossFilterScopingForm/CrossFilterScopingForm.test.tsx +++ /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 { render } from 'spec/helpers/testing-library'; -import FilterScope from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/FilterScope'; -import CrossFilterScopingForm from '.'; - -jest.mock( - 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/FilterScope', - () => jest.fn(() => null), -); - -const createProps = () => { - const getFieldValue = jest.fn(); - getFieldValue.mockImplementation(name => name); - return { - chartId: 123, - scope: 'Scope', - form: { getFieldValue }, - }; -}; - -test('Should send correct props', () => { - const props = createProps(); - render(); - - expect(FilterScope).toHaveBeenCalledWith( - expect.objectContaining({ - chartId: 123, - filterScope: 'Scope', - formFilterScope: 'scope', - formScopingType: 'scoping', - }), - {}, - ); -}); - -test('Should get correct fields', () => { - const props = createProps(); - render(); - expect(props.form.getFieldValue).toBeCalledTimes(2); - expect(props.form.getFieldValue).toHaveBeenNthCalledWith(1, 'scope'); - expect(props.form.getFieldValue).toHaveBeenNthCalledWith(2, 'scoping'); -}); diff --git a/superset-frontend/src/dashboard/components/CrossFilterScopingModal/CrossFilterScopingForm/index.tsx b/superset-frontend/src/dashboard/components/CrossFilterScopingModal/CrossFilterScopingForm/index.tsx deleted file mode 100644 index b0d138cc96901..0000000000000 --- a/superset-frontend/src/dashboard/components/CrossFilterScopingModal/CrossFilterScopingForm/index.tsx +++ /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. - */ -import React, { FC } from 'react'; -import { FormInstance } from 'src/components'; -import { NativeFilterScope } from '@superset-ui/core'; -import FilterScope from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/FilterScope'; -import { setCrossFilterFieldValues } from 'src/dashboard/components/CrossFilterScopingModal/utils'; -import { useForceUpdate } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils'; -import { CrossFilterScopingFormType } from 'src/dashboard/components/CrossFilterScopingModal/types'; - -type CrossFilterScopingFormProps = { - chartId: number; - scope: NativeFilterScope; - form: FormInstance; -}; - -const CrossFilterScopingForm: FC = ({ - form, - scope, - chartId, -}) => { - const forceUpdate = useForceUpdate(); - const formScope = form.getFieldValue('scope'); - const formScoping = form.getFieldValue('scoping'); - return ( - { - setCrossFilterFieldValues(form, { - ...values, - }); - }} - filterScope={scope} - chartId={chartId} - formFilterScope={formScope} - forceUpdate={forceUpdate} - formScopingType={formScoping} - /> - ); -}; - -export default CrossFilterScopingForm; diff --git a/superset-frontend/src/dashboard/components/CrossFilterScopingModal/CrossFilterScopingModal.tsx b/superset-frontend/src/dashboard/components/CrossFilterScopingModal/CrossFilterScopingModal.tsx deleted file mode 100644 index cdd9364b4b82d..0000000000000 --- a/superset-frontend/src/dashboard/components/CrossFilterScopingModal/CrossFilterScopingModal.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { t } from '@superset-ui/core'; -import React, { FC } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { StyledModal } from 'src/components/Modal'; -import Button from 'src/components/Button'; -import { AntdForm } from 'src/components'; -import { setChartConfiguration } from 'src/dashboard/actions/dashboardInfo'; -import { ChartConfiguration } from 'src/dashboard/reducers/types'; -import { ChartsState, Layout, RootState } from 'src/dashboard/types'; -import { getChartIdsInFilterScope } from 'src/dashboard/util/getChartIdsInFilterScope'; -import CrossFilterScopingForm from './CrossFilterScopingForm'; -import { CrossFilterScopingFormType } from './types'; -import { StyledForm } from '../nativeFilters/FiltersConfigModal/FiltersConfigModal'; - -type CrossFilterScopingModalProps = { - chartId: number; - isOpen: boolean; - onClose: () => void; -}; - -const CrossFilterScopingModal: FC = ({ - isOpen, - chartId, - onClose, -}) => { - const dispatch = useDispatch(); - const [form] = AntdForm.useForm(); - const chartConfig = useSelector( - ({ dashboardInfo }) => dashboardInfo?.metadata?.chart_configuration, - ); - const charts = useSelector(state => state.charts); - const layout = useSelector( - state => state.dashboardLayout.present, - ); - const scope = chartConfig?.[chartId]?.crossFilters?.scope; - const handleSave = () => { - const chartsInScope = getChartIdsInFilterScope( - form.getFieldValue('scope'), - charts, - layout, - ); - - dispatch( - setChartConfiguration({ - ...chartConfig, - [chartId]: { - id: chartId, - crossFilters: { scope: form.getFieldValue('scope'), chartsInScope }, - }, - }), - ); - onClose(); - }; - - return ( - - - - - } - > - - - - - ); -}; - -export default CrossFilterScopingModal; diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx index fc651e52b706c..3a641660da418 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx @@ -281,7 +281,6 @@ describe('DashboardBuilder', () => { dashboardInfo: { ...mockState.dashboardInfo, dash_edit_perm: true, - metadata: { show_native_filters: true }, }, }); const filterbar = getByTestId('dashboard-filters-panel'); diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts index 850d195f44e07..807228213891c 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts @@ -30,11 +30,6 @@ import { // eslint-disable-next-line import/prefer-default-export export const useNativeFilters = () => { const [isInitialized, setIsInitialized] = useState(false); - const showNativeFilters = useSelector( - state => - (getUrlParam(URL_PARAMS.showFilters) ?? true) && - state.dashboardInfo.metadata?.show_native_filters, - ); const canEdit = useSelector( ({ dashboardInfo }) => dashboardInfo.dash_edit_perm, ); @@ -47,7 +42,6 @@ export const useNativeFilters = () => { ); const nativeFiltersEnabled = - showNativeFilters && isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) && (canEdit || (!canEdit && filterValues.length !== 0)); diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/DetailsPanel.test.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/DetailsPanel.test.tsx index 3c3e25ee56a8e..ef4b49d218808 100644 --- a/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/DetailsPanel.test.tsx +++ b/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/DetailsPanel.test.tsx @@ -86,7 +86,7 @@ const createProps = () => ({ onHighlightFilterSource: jest.fn(), }); -test('Should render "appliedCrossFilterIndicators"', () => { +test('Should render "appliedCrossFilterIndicators"', async () => { const props = createProps(); props.appliedIndicators = []; props.incompatibleIndicators = []; @@ -99,8 +99,10 @@ test('Should render "appliedCrossFilterIndicators"', () => { { useRedux: true }, ); - userEvent.click(screen.getByTestId('details-panel-content')); - expect(screen.getByText('Applied Cross Filters (1)')).toBeInTheDocument(); + userEvent.hover(screen.getByTestId('details-panel-content')); + expect( + await screen.findByText('Applied cross-filters (1)'), + ).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Clinical Stage' }), ).toBeInTheDocument(); @@ -118,7 +120,7 @@ test('Should render "appliedCrossFilterIndicators"', () => { ]); }); -test('Should render "appliedIndicators"', () => { +test('Should render "appliedIndicators"', async () => { const props = createProps(); props.appliedCrossFilterIndicators = []; props.incompatibleIndicators = []; @@ -131,8 +133,8 @@ test('Should render "appliedIndicators"', () => { { useRedux: true }, ); - userEvent.click(screen.getByTestId('details-panel-content')); - expect(screen.getByText('Applied Filters (1)')).toBeInTheDocument(); + userEvent.hover(screen.getByTestId('details-panel-content')); + expect(await screen.findByText('Applied filters (1)')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Country' })).toBeInTheDocument(); expect(props.onHighlightFilterSource).toBeCalledTimes(0); @@ -148,72 +150,6 @@ test('Should render "appliedIndicators"', () => { ]); }); -test('Should render "incompatibleIndicators"', () => { - const props = createProps(); - props.appliedCrossFilterIndicators = []; - props.appliedIndicators = []; - props.unsetIndicators = []; - - render( - -
Content
- , - { useRedux: true }, - ); - - userEvent.click(screen.getByTestId('details-panel-content')); - expect(screen.getByText('Incompatible Filters (1)')).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Vaccine Approach Copy' }), - ).toBeInTheDocument(); - - expect(props.onHighlightFilterSource).toBeCalledTimes(0); - userEvent.click( - screen.getByRole('button', { name: 'Vaccine Approach Copy' }), - ); - expect(props.onHighlightFilterSource).toBeCalledTimes(1); - expect(props.onHighlightFilterSource).toBeCalledWith([ - 'ROOT_ID', - 'TABS-wUKya7eQ0Zz', - 'TAB-BCIJF4NvgQq', - 'ROW-xSeNAspgww', - 'CHART-eirDduqb1Aa', - 'LABEL-product_category_copy', - ]); -}); - -test('Should render "unsetIndicators"', () => { - const props = createProps(); - props.appliedCrossFilterIndicators = []; - props.appliedIndicators = []; - props.incompatibleIndicators = []; - - render( - -
Content
-
, - { useRedux: true }, - ); - - userEvent.click(screen.getByTestId('details-panel-content')); - expect(screen.getByText('Unset Filters (1)')).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Vaccine Approach' }), - ).toBeInTheDocument(); - - expect(props.onHighlightFilterSource).toBeCalledTimes(0); - userEvent.click(screen.getByRole('button', { name: 'Vaccine Approach' })); - expect(props.onHighlightFilterSource).toBeCalledTimes(1); - expect(props.onHighlightFilterSource).toBeCalledWith([ - 'ROOT_ID', - 'TABS-wUKya7eQ0Z', - 'TAB-BCIJF4NvgQ', - 'ROW-xSeNAspgw', - 'CHART-eirDduqb1A', - 'LABEL-product_category', - ]); -}); - test('Should render empty', () => { const props = createProps(); props.appliedCrossFilterIndicators = []; diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/index.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/index.tsx index 022dab796875f..53d20cd1f894c 100644 --- a/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/index.tsx +++ b/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/index.tsx @@ -19,31 +19,21 @@ import React, { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import { Global, css } from '@emotion/react'; -import { t, useTheme } from '@superset-ui/core'; +import { t } from '@superset-ui/core'; import Popover from 'src/components/Popover'; -import Collapse from 'src/components/Collapse'; -import Icons from 'src/components/Icons'; import { - Indent, - Panel, - Reset, - Title, + FiltersContainer, + FiltersDetailsContainer, + Separator, + SectionName, } from 'src/dashboard/components/FiltersBadge/Styles'; import { Indicator } from 'src/dashboard/components/nativeFilters/selectors'; import FilterIndicator from 'src/dashboard/components/FiltersBadge/FilterIndicator'; import { RootState } from 'src/dashboard/types'; -const iconReset = css` - span { - line-height: 0; - } -`; - export interface DetailsPanelProps { appliedCrossFilterIndicators: Indicator[]; appliedIndicators: Indicator[]; - incompatibleIndicators: Indicator[]; - unsetIndicators: Indicator[]; onHighlightFilterSource: (path: string[]) => void; children: JSX.Element; } @@ -51,13 +41,10 @@ export interface DetailsPanelProps { const DetailsPanelPopover = ({ appliedCrossFilterIndicators = [], appliedIndicators = [], - incompatibleIndicators = [], - unsetIndicators = [], onHighlightFilterSource, children, }: DetailsPanelProps) => { const [visible, setVisible] = useState(false); - const theme = useTheme(); const activeTabs = useSelector( state => state.dashboardState?.activeTabs, ); @@ -76,57 +63,22 @@ const DetailsPanelPopover = ({ setVisible(false); }, [activeTabs]); - const getDefaultActivePanel = () => { - const result = []; - if (appliedCrossFilterIndicators.length) { - result.push('appliedCrossFilters'); - } - if (appliedIndicators.length) { - result.push('applied'); - } - if (incompatibleIndicators.length) { - result.push('incompatible'); - } - if (result.length) { - return result; - } - return ['unset']; - }; - - const [activePanels, setActivePanels] = useState(() => [ - ...getDefaultActivePanel(), - ]); - function handlePopoverStatus(isOpen: boolean) { setVisible(isOpen); - // every time the popover opens, make sure the most relevant panel is active - if (isOpen) { - setActivePanels(getDefaultActivePanel()); - } - } - - function handleActivePanelChange(panels: string | string[]) { - // need to convert to an array so that handlePopoverStatus will work - if (typeof panels === 'string') { - setActivePanels([panels]); - } else { - setActivePanels(panels); - } } const indicatorKey = (indicator: Indicator): string => `${indicator.column} - ${indicator.name}`; const content = ( - + css` .filterStatusPopover { .ant-popover-inner { background-color: ${theme.colors.grayscale.dark2}cc; .ant-popover-inner-content { - padding-top: 0; - padding-bottom: 0; + padding: ${theme.gridUnit * 2}px; } } &.ant-popover-placement-bottom, @@ -168,110 +120,47 @@ const DetailsPanelPopover = ({ } `} /> - - - {appliedCrossFilterIndicators.length ? ( - - - {t( - 'Applied Cross Filters (%d)', - appliedCrossFilterIndicators.length, - )} - - } - > - - {appliedCrossFilterIndicators.map(indicator => ( - - ))} - - - ) : null} - {appliedIndicators.length ? ( - - {' '} - {t('Applied Filters (%d)', appliedIndicators.length)} - - } - > - - {appliedIndicators.map(indicator => ( - - ))} - - - ) : null} - {incompatibleIndicators.length ? ( - - {' '} - {t( - 'Incompatible Filters (%d)', - incompatibleIndicators.length, - )} - - } - > - - {incompatibleIndicators.map(indicator => ( - - ))} - - - ) : null} - {unsetIndicators.length ? ( - - {' '} - {t('Unset Filters (%d)', unsetIndicators.length)} - - } - disabled={!unsetIndicators.length} - > - - {unsetIndicators.map(indicator => ( - - ))} - - - ) : null} - - - +
+ {appliedCrossFilterIndicators.length ? ( +
+ + {t( + 'Applied cross-filters (%d)', + appliedCrossFilterIndicators.length, + )} + + + {appliedCrossFilterIndicators.map(indicator => ( + + ))} + +
+ ) : null} + {appliedCrossFilterIndicators.length && appliedIndicators.length ? ( + + ) : null} + {appliedIndicators.length ? ( +
+ + {t('Applied filters (%d)', appliedIndicators.length)} + + + {appliedIndicators.map(indicator => ( + + ))} + +
+ ) : null} +
+ ); return ( @@ -281,7 +170,7 @@ const DetailsPanelPopover = ({ visible={visible} onVisibleChange={handlePopoverStatus} placement="bottomRight" - trigger="click" + trigger="hover" > {children} diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/FilterIndicator/index.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/FilterIndicator/index.tsx index 2954474e64eac..ed09e1679078b 100644 --- a/superset-frontend/src/dashboard/components/FiltersBadge/FilterIndicator/index.tsx +++ b/superset-frontend/src/dashboard/components/FiltersBadge/FilterIndicator/index.tsx @@ -22,47 +22,48 @@ import { css } from '@superset-ui/core'; import Icons from 'src/components/Icons'; import { getFilterValueForDisplay } from 'src/dashboard/components/nativeFilters/FilterBar/FilterSets/utils'; import { - FilterIndicatorText, FilterValue, - Item, - ItemIcon, - Title, + FilterItem, + FilterName, } from 'src/dashboard/components/FiltersBadge/Styles'; import { Indicator } from 'src/dashboard/components/nativeFilters/selectors'; export interface IndicatorProps { indicator: Indicator; onClick?: (path: string[]) => void; - text?: string; } const FilterIndicator: FC = ({ indicator: { column, name, value, path = [] }, - onClick = () => {}, - text, + onClick, }) => { const resultValue = getFilterValueForDisplay(value); return ( - <> - onClick([...path, `LABEL-${column}`])}> - - <ItemIcon> - <Icons.SearchOutlined - iconSize="m" - css={css` - span { - vertical-align: 0; - } - `} - /> - </ItemIcon> + <FilterItem + onClick={ + onClick ? () => onClick([...path, `LABEL-${column}`]) : undefined + } + > + {onClick && ( + <i> + <Icons.SearchOutlined + iconSize="m" + css={css` + span { + vertical-align: 0; + } + `} + /> + </i> + )} + <div> + <FilterName> {name} {resultValue ? ': ' : ''} - + {resultValue} - - {text && {text}} - + + ); }; diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/FiltersBadge.test.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/FiltersBadge.test.tsx index 2a1f7bcfb03bc..cae293bd740e1 100644 --- a/superset-frontend/src/dashboard/components/FiltersBadge/FiltersBadge.test.tsx +++ b/superset-frontend/src/dashboard/components/FiltersBadge/FiltersBadge.test.tsx @@ -36,7 +36,6 @@ import { import { sliceId } from 'spec/fixtures/mockChartQueries'; import { dashboardFilters } from 'spec/fixtures/mockDashboardFilters'; import { dashboardWithFilter } from 'spec/fixtures/mockDashboardLayout'; -import Icons from 'src/components/Icons'; import { FeatureFlag } from 'src/featureFlags'; const defaultStore = getMockStoreWithFilters(); @@ -106,40 +105,10 @@ describe('FiltersBadge', () => { store.dispatch({ type: CHART_RENDERING_SUCCEEDED, key: sliceId }); const wrapper = setup(store); expect(wrapper.find('DetailsPanelPopover')).toExist(); - expect(wrapper.find('[data-test="applied-filter-count"]')).toHaveText( - '1', - ); - expect(wrapper.find('WarningFilled')).not.toExist(); - }); - - it("shows a warning when there's a rejected filter", () => { - const store = getMockStoreWithFilters(); - // start with basic dashboard state, dispatch an event to simulate query completion - store.dispatch({ - type: CHART_UPDATE_SUCCEEDED, - key: sliceId, - queriesResponse: [ - { - status: 'success', - applied_filters: [], - rejected_filters: [ - { column: 'region', reason: 'not_in_datasource' }, - ], - }, - ], - dashboardFilters, - }); - store.dispatch({ type: CHART_RENDERING_SUCCEEDED, key: sliceId }); - const wrapper = setup(store); - expect(wrapper.find('DetailsPanelPopover')).toExist(); - expect(wrapper.find('[data-test="applied-filter-count"]')).toHaveText( - '0', - ); expect( - wrapper.find('[data-test="incompatible-filter-count"]'), + wrapper.find('[data-test="applied-filter-count"] .current'), ).toHaveText('1'); - // to look at the shape of the wrapper use: - expect(wrapper.find(Icons.AlertSolid)).toExist(); + expect(wrapper.find('WarningFilled')).not.toExist(); }); }); @@ -184,42 +153,10 @@ describe('FiltersBadge', () => { store.dispatch({ type: CHART_RENDERING_SUCCEEDED, key: sliceId }); const wrapper = setup(store); expect(wrapper.find('DetailsPanelPopover')).toExist(); - expect(wrapper.find('[data-test="applied-filter-count"]')).toHaveText( - '1', - ); - expect(wrapper.find('WarningFilled')).not.toExist(); - }); - - it("shows a warning when there's a rejected filter", () => { - // @ts-ignore - global.featureFlags = { - [FeatureFlag.DASHBOARD_NATIVE_FILTERS]: true, - }; - const store = getMockStoreWithNativeFilters(); - // start with basic dashboard state, dispatch an event to simulate query completion - store.dispatch({ - type: CHART_UPDATE_SUCCEEDED, - key: sliceId, - queriesResponse: [ - { - status: 'success', - applied_filters: [], - rejected_filters: [ - { column: 'region', reason: 'not_in_datasource' }, - ], - }, - ], - }); - store.dispatch({ type: CHART_RENDERING_SUCCEEDED, key: sliceId }); - const wrapper = setup(store); - expect(wrapper.find('DetailsPanelPopover')).toExist(); - expect(wrapper.find('[data-test="applied-filter-count"]')).toHaveText( - '0', - ); expect( - wrapper.find('[data-test="incompatible-filter-count"]'), + wrapper.find('[data-test="applied-filter-count"] .current'), ).toHaveText('1'); - expect(wrapper.find(Icons.AlertSolid)).toExist(); + expect(wrapper.find('WarningFilled')).not.toExist(); }); }); }); diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/Styles.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/Styles.tsx index c80e0ec0cb734..1b3be657af31b 100644 --- a/superset-frontend/src/dashboard/components/FiltersBadge/Styles.tsx +++ b/superset-frontend/src/dashboard/components/FiltersBadge/Styles.tsx @@ -20,7 +20,7 @@ import { css, styled } from '@superset-ui/core'; export const Pill = styled.div` ${({ theme }) => css` - display: inline-block; + display: flex; color: ${theme.colors.grayscale.light5}; background: ${theme.colors.grayscale.base}; border-radius: 1em; @@ -36,7 +36,6 @@ export const Pill = styled.div` svg { position: relative; - top: -2px; color: ${theme.colors.grayscale.light5}; width: 1em; height: 1em; @@ -55,113 +54,81 @@ export const Pill = styled.div` background: ${theme.colors.primary.dark1}; } } - - &.has-incompatible-filters { - color: ${theme.colors.grayscale.dark2}; - background: ${theme.colors.alert.base}; - &:hover { - background: ${theme.colors.alert.dark1}; - } - svg { - color: ${theme.colors.grayscale.dark2}; - } - } - - &.filters-inactive { - color: ${theme.colors.grayscale.light5}; - background: ${theme.colors.grayscale.light1}; - padding: ${theme.gridUnit}px; - text-align: center; - height: 22px; - width: 22px; - - &:hover { - background: ${theme.colors.grayscale.base}; - } - } `} `; -export interface TitleProps { - bold?: boolean; - color?: string; -} - -export const Title = styled.span` - position: relative; - margin-right: ${({ theme }) => theme.gridUnit}px; - font-weight: ${({ bold, theme }) => { - if (bold) return theme.typography.weights.bold; - return 'auto'; - }}; - color: ${({ color, theme }) => color || theme.colors.grayscale.light5}; - display: flex; - align-items: center; - & > * { - margin-right: ${({ theme }) => theme.gridUnit}px; - } +export const SectionName = styled.span` + ${({ theme }) => css` + font-weight: ${theme.typography.weights.bold}; + `} `; - -export const ItemIcon = styled.i` - position: absolute; - top: 50%; - transform: translateY(-50%); - left: -${({ theme }) => theme.gridUnit * 5}px; +export const FilterName = styled.span` + ${({ theme }) => css` + padding-right: ${theme.gridUnit}px; + font-style: italic; + & > * { + margin-right: ${theme.gridUnit}px; + } + `} `; -export const Item = styled.button` - cursor: pointer; - display: flex; - flex-wrap: wrap; - text-align: left; - padding: 0; - border: none; - background: none; - outline: none; - width: 100%; - - &::-moz-focus-inner { - border: 0; - } +export const FilterItem = styled.button` + ${({ theme }) => css` + cursor: pointer; + display: flex; + text-align: left; + padding: 0; + border: none; + background: none; + outline: none; + width: 100%; + + &::-moz-focus-inner { + border: 0; + } - & i svg { - color: transparent; - margin-right: ${({ theme }) => theme.gridUnit}px; - } + & i svg { + opacity: ${theme.opacity.mediumLight}; + margin-right: ${theme.gridUnit}px; + transition: opacity ease-in-out ${theme.transitionTiming}; + } - &:hover i svg { - color: inherit; - } + &:hover i svg { + opacity: 1; + } + `} `; -export const Reset = styled.div` - margin: 0 -${({ theme }) => theme.gridUnit * 4}px; +export const FiltersContainer = styled.div` + ${({ theme }) => css` + margin-top: ${theme.gridUnit}px; + &:not(:last-child) { + padding-bottom: ${theme.gridUnit * 3}px; + } + `} `; -export const Indent = styled.div` - padding-left: ${({ theme }) => theme.gridUnit * 6}px; - margin: -${({ theme }) => theme.gridUnit * 3}px 0; -`; +export const FiltersDetailsContainer = styled.div` + ${({ theme }) => css` + min-width: 200px; + max-width: 300px; + overflow-x: hidden; -export const Panel = styled.div` - min-width: 200px; - max-width: 300px; - overflow-x: hidden; + color: ${theme.colors.grayscale.light5}; + `} `; -export const FilterValue = styled.div` +export const FilterValue = styled.span` max-width: 100%; flex-grow: 1; overflow: auto; - color: ${({ theme }) => theme.colors.grayscale.light5}; `; -export const FilterIndicatorText = styled.div` - ${({ theme }) => ` - padding-top: ${theme.gridUnit * 3}px; - max-width: 100%; - flex-grow: 1; - overflow: auto; - color: ${theme.colors.grayscale.light5}; +export const Separator = styled.div` + ${({ theme }) => css` + width: 100%; + height: 1px; + background-color: ${theme.colors.grayscale.light1}; + margin: ${theme.gridUnit * 4}px 0; `} `; diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx index fb0718bf54cb4..f2d102ad8d2e5 100644 --- a/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx +++ b/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx @@ -20,12 +20,12 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { uniqWith } from 'lodash'; import cx from 'classnames'; -import { DataMaskStateWithId, Filters } from '@superset-ui/core'; +import { DataMaskStateWithId, Filters, styled } from '@superset-ui/core'; import Icons from 'src/components/Icons'; import { usePrevious } from 'src/hooks/usePrevious'; import { setDirectPathToChild } from 'src/dashboard/actions/dashboardState'; +import Badge from 'src/components/Badge'; import DetailsPanelPopover from './DetailsPanel'; -import { Pill } from './Styles'; import { Indicator, IndicatorStatus, @@ -43,6 +43,48 @@ export interface FiltersBadgeProps { chartId: number; } +const StyledFilterCount = styled.div` + ${({ theme }) => ` + display: flex; + justify-items: center; + align-items: center; + cursor: pointer; + margin-right: ${theme.gridUnit}px; + padding-left: ${theme.gridUnit * 2}px; + padding-right: ${theme.gridUnit * 2}px; + background: ${theme.colors.grayscale.light4}; + border-radius: 4px; + height: 100%; + .anticon { + vertical-align: middle; + color: ${theme.colors.grayscale.base}; + &:hover { + color: ${theme.colors.grayscale.light1} + } + } + + .incompatible-count { + font-size: ${theme.typography.sizes.s}px; + } + `} +`; + +const StyledBadge = styled(Badge)` + ${({ theme }) => ` + vertical-align: middle; + margin-left: ${theme.gridUnit * 2}px; + &>sup { + padding: 0 ${theme.gridUnit}px; + min-width: ${theme.gridUnit * 4}px; + height: ${theme.gridUnit * 4}px; + line-height: 1.5; + font-weight: ${theme.typography.weights.medium}; + font-size: ${theme.typography.sizes.s - 1}px; + box-shadow: none; + } + `} +`; + const sortByStatus = (indicators: Indicator[]): Indicator[] => { const statuses = [ IndicatorStatus.Applied, @@ -211,67 +253,31 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => { ), [indicators], ); - const unsetIndicators = useMemo( - () => - indicators.filter( - indicator => indicator.status === IndicatorStatus.Unset, - ), - [indicators], - ); - const incompatibleIndicators = useMemo( - () => - indicators.filter( - indicator => indicator.status === IndicatorStatus.Incompatible, - ), - [indicators], - ); - if ( - !appliedCrossFilterIndicators.length && - !appliedIndicators.length && - !incompatibleIndicators.length && - !unsetIndicators.length - ) { + if (!appliedCrossFilterIndicators.length && !appliedIndicators.length) { return null; } - const isInactive = - !appliedCrossFilterIndicators.length && - !appliedIndicators.length && - !incompatibleIndicators.length; - return ( - - {!isInactive && ( - - {appliedIndicators.length + appliedCrossFilterIndicators.length} - - )} - {incompatibleIndicators.length ? ( - <> - {' '} - - - {incompatibleIndicators.length} - - - ) : null} - + + ); }; diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx index a928933b54a27..845a9a9515d1e 100644 --- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx @@ -21,14 +21,13 @@ import React, { ReactNode, useContext, useEffect, - useMemo, useRef, useState, } from 'react'; import { css, styled, t } from '@superset-ui/core'; import { useUiConfig } from 'src/components/UiConfigContext'; import { Tooltip } from 'src/components/Tooltip'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import EditableTitle from 'src/components/EditableTitle'; import SliceHeaderControls, { SliceHeaderControlsProps, @@ -36,10 +35,8 @@ import SliceHeaderControls, { import FiltersBadge from 'src/dashboard/components/FiltersBadge'; import Icons from 'src/components/Icons'; import { RootState } from 'src/dashboard/types'; -import FilterIndicator from 'src/dashboard/components/FiltersBadge/FilterIndicator'; import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'; import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage'; -import { clearDataMask } from 'src/dataMask/actions'; type SliceHeaderProps = SliceHeaderControlsProps & { innerRef?: string; @@ -57,11 +54,12 @@ type SliceHeaderProps = SliceHeaderControlsProps & { const annotationsLoading = t('Annotation layers are still loading.'); const annotationsError = t('One ore more annotation layers failed loading.'); -const CrossFilterIcon = styled(Icons.CursorTarget)` - cursor: pointer; - color: ${({ theme }) => theme.colors.primary.base}; - height: 22px; - width: 22px; +const CrossFilterIcon = styled(Icons.ApartmentOutlined)` + ${({ theme }) => ` + cursor: default; + color: ${theme.colors.primary.base}; + line-height: 1.8; + `} `; const ChartHeaderStyles = styled.div` @@ -90,6 +88,8 @@ const ChartHeaderStyles = styled.div` & > .header-controls { display: flex; + align-items: center; + height: 24px; & > * { margin-left: ${theme.gridUnit * 2}px; @@ -160,7 +160,6 @@ const SliceHeader: FC = ({ width, height, }) => { - const dispatch = useDispatch(); const uiConfig = useUiConfig(); const dashboardPageId = useContext(DashboardPageIdContext); const [headerTooltip, setHeaderTooltip] = useState(null); @@ -173,14 +172,6 @@ const SliceHeader: FC = ({ ({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled, ); - const indicator = useMemo( - () => ({ - value: crossFilterValue, - name: t('Emitted values'), - }), - [crossFilterValue], - ); - const canExplore = !editMode && supersetCanExplore; useEffect(() => { @@ -250,16 +241,11 @@ const SliceHeader: FC = ({ {crossFilterValue && ( - } + title={t( + 'This chart emits/applies cross-filters to other charts that use the same dataset', + )} > - dispatch(clearDataMask(slice?.slice_id))} - /> + )} {!uiConfig.hideChartControls && ( diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 4037234f74fd0..2d82b39d861a8 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -30,21 +30,12 @@ import { withRouter, } from 'react-router-dom'; import moment from 'moment'; -import { - Behavior, - css, - getChartMetadataRegistry, - QueryFormData, - styled, - t, - useTheme, -} from '@superset-ui/core'; +import { css, QueryFormData, styled, t, useTheme } from '@superset-ui/core'; import { Menu } from 'src/components/Menu'; import { NoAnimationDropdown } from 'src/components/Dropdown'; import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems'; import downloadAsImage from 'src/utils/downloadAsImage'; import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags'; -import CrossFilterScopingModal from 'src/dashboard/components/CrossFilterScopingModal/CrossFilterScopingModal'; import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'; import { Tooltip } from 'src/components/Tooltip'; import Icons from 'src/components/Icons'; @@ -57,7 +48,6 @@ import { DrillDetailMenuItems } from 'src/components/Chart/DrillDetail'; import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils'; const MENU_KEYS = { - CROSS_FILTER_SCOPING: 'cross_filter_scoping', DOWNLOAD_AS_IMAGE: 'download_as_image', EXPLORE_CHART: 'explore_chart', EXPORT_CSV: 'export_csv', @@ -157,7 +147,6 @@ type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps & RouteComponentProps; interface State { showControls: boolean; - showCrossFilterScopingModal: boolean; } const dropdownIconsStyles = css` @@ -256,7 +245,6 @@ class SliceHeaderControls extends React.PureComponent< this.state = { showControls: false, - showCrossFilterScopingModal: false, }; } @@ -287,9 +275,6 @@ class SliceHeaderControls extends React.PureComponent< this.refreshChart(); this.props.addSuccessToast(t('Data refreshed')); break; - case MENU_KEYS.CROSS_FILTER_SCOPING: - this.setState({ showCrossFilterScopingModal: true }); - break; case MENU_KEYS.TOGGLE_CHART_DESCRIPTION: // eslint-disable-next-line no-unused-expressions this.props.toggleExpandSlice?.(this.props.slice.slice_id); @@ -346,17 +331,8 @@ class SliceHeaderControls extends React.PureComponent< addDangerToast = () => {}, supersetCanShare = false, isCached = [], - crossFiltersEnabled, } = this.props; - const crossFilterItems = getChartMetadataRegistry().items; const isTable = slice.viz_type === 'table'; - const isCrossFilter = Object.entries(crossFilterItems) - // @ts-ignore - .filter(([, { value }]) => - value.behaviors?.includes(Behavior.INTERACTIVE_CHART), - ) - .find(([key]) => key === slice.viz_type); - const cachedWhen = (cachedDttm || []).map(itemCachedDttm => moment.utc(itemCachedDttm).fromNow(), ); @@ -477,17 +453,6 @@ class SliceHeaderControls extends React.PureComponent< )} - {isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS) && - isCrossFilter && - crossFiltersEnabled && ( - <> - - {t('Cross-filter scoping')} - - - - )} - {supersetCanShare && ( - this.setState({ showCrossFilterScopingModal: false })} - /> {isFullSize && ( { if (path) { - dispatch(setFocusedNativeFilter(path[0])); + dispatch(setDirectPathToChild(path)); } }, [dispatch], diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/Vertical.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/Vertical.tsx index 93fb649d686f6..907e73c39e930 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/Vertical.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/Vertical.tsx @@ -18,9 +18,9 @@ */ import React from 'react'; -import { DataMaskStateWithId } from '@superset-ui/core'; +import { DataMaskStateWithId, JsonObject } from '@superset-ui/core'; import { useSelector } from 'react-redux'; -import { DashboardInfo, DashboardLayout, RootState } from 'src/dashboard/types'; +import { DashboardLayout, RootState } from 'src/dashboard/types'; import crossFiltersSelector from './selectors'; import VerticalCollapse from './VerticalCollapse'; @@ -28,15 +28,15 @@ const CrossFiltersVertical = () => { const dataMask = useSelector( state => state.dataMask, ); - const dashboardInfo = useSelector( - state => state.dashboardInfo, + const chartConfiguration = useSelector( + state => state.dashboardInfo.metadata?.chart_configuration, ); const dashboardLayout = useSelector( state => state.dashboardLayout.present, ); const selectedCrossFilters = crossFiltersSelector({ dataMask, - dashboardInfo, + chartConfiguration, dashboardLayout, }); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/selectors.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/selectors.ts index c0f45af5b3e6c..bf19ecabf9e2e 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/selectors.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/selectors.ts @@ -17,37 +17,35 @@ * under the License. */ -import { DataMaskStateWithId } from '@superset-ui/core'; -import { DashboardInfo, DashboardLayout } from 'src/dashboard/types'; -import { CrossFilterIndicator, selectChartCrossFilters } from '../../selectors'; +import { DataMaskStateWithId, isDefined, JsonObject } from '@superset-ui/core'; +import { DashboardLayout } from 'src/dashboard/types'; +import { CrossFilterIndicator, getCrossFilterIndicator } from '../../selectors'; export const crossFiltersSelector = (props: { dataMask: DataMaskStateWithId; - dashboardInfo: DashboardInfo; + chartConfiguration: JsonObject; dashboardLayout: DashboardLayout; }): CrossFilterIndicator[] => { - const { dataMask, dashboardInfo, dashboardLayout } = props; - const chartConfiguration = dashboardInfo.metadata?.chart_configuration; + const { dataMask, chartConfiguration, dashboardLayout } = props; const chartsIds = Object.keys(chartConfiguration); - const shouldFilterEmitters = true; - let selectedCrossFilters: CrossFilterIndicator[] = []; - - for (let i = 0; i < chartsIds.length; i += 1) { - const chartId = Number(chartsIds[i]); - const crossFilters = selectChartCrossFilters( - dataMask, - chartId, - dashboardLayout, - chartConfiguration, - shouldFilterEmitters, - ); - selectedCrossFilters = [ - ...selectedCrossFilters, - ...(crossFilters as CrossFilterIndicator[]), - ]; - } - return selectedCrossFilters; + return chartsIds + .map(chartId => { + const id = Number(chartId); + const filterIndicator = getCrossFilterIndicator( + id, + dataMask[id], + dashboardLayout, + ); + if ( + isDefined(filterIndicator.column) && + isDefined(filterIndicator.value) + ) { + return { ...filterIndicator, emitterId: id }; + } + return null; + }) + .filter(Boolean) as CrossFilterIndicator[]; }; export default crossFiltersSelector; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/FilterBarSettings.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/FilterBarSettings.test.tsx index 344e891365a50..10fd66d7078a0 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/FilterBarSettings.test.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/FilterBarSettings.test.tsx @@ -33,7 +33,6 @@ const initialState: { dashboardInfo: DashboardInfo } = { userId: '1', metadata: { native_filter_configuration: {}, - show_native_filters: true, chart_configuration: {}, color_scheme: '', color_namespace: '', diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx index dda3b7e47ba08..75572d74e51a5 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx @@ -144,7 +144,7 @@ const FilterBarSettings = () => { ); const menuItems: DropDownSelectableProps['menuItems'] = []; - if (isCrossFiltersFeatureEnabled) { + if (isCrossFiltersFeatureEnabled && canEdit) { menuItems.unshift({ key: crossFiltersMenuKey, label: crossFiltersMenuItem, diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx index d4ec83c1e2abd..b44591f4b1a9c 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx @@ -35,6 +35,7 @@ import { isFeatureEnabled, FeatureFlag, isNativeFilterWithDataMask, + JsonObject, } from '@superset-ui/core'; import { createHtmlPortalNode, @@ -47,7 +48,6 @@ import { useSelectFiltersInScope, } from 'src/dashboard/components/nativeFilters/state'; import { - DashboardInfo, DashboardLayout, FilterBarOrientation, RootState, @@ -87,8 +87,8 @@ const FilterControls: FC = ({ const dataMask = useSelector( state => state.dataMask, ); - const dashboardInfo = useSelector( - state => state.dashboardInfo, + const chartConfiguration = useSelector( + state => state.dashboardInfo.metadata?.chart_configuration, ); const dashboardLayout = useSelector( state => state.dashboardLayout.present, @@ -101,11 +101,11 @@ const FilterControls: FC = ({ isCrossFiltersEnabled ? crossFiltersSelector({ dataMask, - dashboardInfo, + chartConfiguration, dashboardLayout, }) : [], - [dashboardInfo, dashboardLayout, dataMask, isCrossFiltersEnabled], + [chartConfiguration, dashboardLayout, dataMask, isCrossFiltersEnabled], ); const { filterControlFactory, filtersWithValues } = useFilterControlFactory( dataMaskSelected, @@ -124,6 +124,11 @@ const FilterControls: FC = ({ const [filtersInScope, filtersOutOfScope] = useSelectFiltersInScope(filtersWithValues); + const hasRequiredFirst = useMemo( + () => filtersWithValues.some(filter => filter.requiredFirst), + [filtersWithValues], + ); + const dashboardHasTabs = useDashboardHasTabs(); const showCollapsePanel = dashboardHasTabs && filtersWithValues.length > 0; @@ -149,6 +154,7 @@ const FilterControls: FC = ({ {showCollapsePanel && ( 0} renderer={renderer} /> @@ -264,6 +270,7 @@ const FilterControls: FC = ({ renderer={renderer} rendererCrossFilter={rendererCrossFilter} showCollapsePanel={showCollapsePanel} + forceRenderOutOfScope={hasRequiredFirst} /> ) : undefined diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersDropdownContent/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersDropdownContent/index.tsx index c4c720710bbd8..39d9816d47aaa 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersDropdownContent/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersDropdownContent/index.tsx @@ -34,6 +34,7 @@ export interface FiltersDropdownContentProps { last: CrossFilterIndicator, ) => ReactNode; showCollapsePanel?: boolean; + forceRenderOutOfScope?: boolean; } export const FiltersDropdownContent = ({ @@ -43,6 +44,7 @@ export const FiltersDropdownContent = ({ renderer, rendererCrossFilter, showCollapsePanel, + forceRenderOutOfScope, }: FiltersDropdownContentProps) => (
@@ -64,6 +66,7 @@ export const FiltersDropdownContent = ({ )} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersOutOfScopeCollapsible/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersOutOfScopeCollapsible/index.tsx index 898ca2768e253..d63ae2179f288 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersOutOfScopeCollapsible/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersOutOfScopeCollapsible/index.tsx @@ -26,6 +26,7 @@ export interface FiltersOutOfScopeCollapsibleProps { renderer: (filter: Filter | Divider, index: number) => ReactNode; hasTopMargin?: boolean; horizontalOverflow?: boolean; + forceRender?: boolean; } export const FiltersOutOfScopeCollapsible = ({ @@ -33,6 +34,7 @@ export const FiltersOutOfScopeCollapsible = ({ renderer, hasTopMargin, horizontalOverflow, + forceRender = false, }: FiltersOutOfScopeCollapsibleProps) => ( css` diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx index badff642955a4..24b2caa033b81 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx @@ -22,12 +22,13 @@ import { DataMaskStateWithId, FeatureFlag, isFeatureEnabled, + JsonObject, styled, t, } from '@superset-ui/core'; import Icons from 'src/components/Icons'; import Loading from 'src/components/Loading'; -import { DashboardInfo, DashboardLayout, RootState } from 'src/dashboard/types'; +import { DashboardLayout, RootState } from 'src/dashboard/types'; import { useSelector } from 'react-redux'; import FilterControls from './FilterControls/FilterControls'; import { getFilterBarTestId } from './utils'; @@ -107,8 +108,8 @@ const HorizontalFilterBar: React.FC = ({ const dataMask = useSelector( state => state.dataMask, ); - const dashboardInfo = useSelector( - state => state.dashboardInfo, + const chartConfiguration = useSelector( + state => state.dashboardInfo.metadata?.chart_configuration, ); const dashboardLayout = useSelector( state => state.dashboardLayout.present, @@ -119,7 +120,7 @@ const HorizontalFilterBar: React.FC = ({ const selectedCrossFilters = isCrossFiltersEnabled ? crossFiltersSelector({ dataMask, - dashboardInfo, + chartConfiguration, dashboardLayout, }) : []; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts b/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts index cec2ecc2e952b..5d4e980a40344 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts @@ -17,12 +17,14 @@ * under the License. */ import { + DataMask, DataMaskStateWithId, DataMaskType, ensureIsArray, FeatureFlag, Filters, FilterState, + getColumnLabel, isFeatureEnabled, NativeFilterType, NO_TIME_RANGE, @@ -31,7 +33,7 @@ import { import { TIME_FILTER_MAP } from 'src/explore/constants'; import { getChartIdsInFilterBoxScope } from 'src/dashboard/util/activeDashboardFilters'; import { ChartConfiguration } from 'src/dashboard/reducers/types'; -import { Layout } from 'src/dashboard/types'; +import { DashboardLayout, Layout } from 'src/dashboard/types'; import { areObjectsEqual } from 'src/reduxUtils'; export enum IndicatorStatus { @@ -60,7 +62,7 @@ type Filter = { datasourceId: string; }; -const extractLabel = (filter?: FilterState): string | null => { +export const extractLabel = (filter?: FilterState): string | null => { if (filter?.label && !filter?.label?.includes(undefined)) { return filter.label; } @@ -146,8 +148,8 @@ const getAppliedColumns = (chart: any): Set => const getRejectedColumns = (chart: any): Set => new Set( - (chart?.queriesResponse?.[0]?.rejected_filters || []).map( - (filter: any) => filter.column, + (chart?.queriesResponse?.[0]?.rejected_filters || []).map((filter: any) => + getColumnLabel(filter.column), ), ); @@ -161,6 +163,36 @@ export type Indicator = { export type CrossFilterIndicator = Indicator & { emitterId: number }; +export const getCrossFilterIndicator = ( + chartId: number, + dataMask: DataMask, + dashboardLayout: DashboardLayout, +) => { + const filterState = dataMask?.filterState; + const filters = dataMask?.extraFormData?.filters; + const label = extractLabel(filterState); + const filtersState = filterState?.filters; + const column = + filters?.[0]?.col || (filtersState && Object.keys(filtersState)[0]); + + const dashboardLayoutItem = Object.values(dashboardLayout).find( + layoutItem => layoutItem?.meta?.chartId === chartId, + ); + const filterObject: Indicator = { + column, + name: + dashboardLayoutItem?.meta?.sliceNameOverride || + dashboardLayoutItem?.meta?.sliceName || + '', + path: [ + ...(dashboardLayoutItem?.parents ?? []), + dashboardLayoutItem?.id || '', + ], + value: label, + }; + return filterObject; +}; + const cachedIndicatorsForChart = {}; const cachedDashboardFilterDataForChart = {}; // inspects redux state to find what the filter indicators should be shown for a given chart @@ -232,17 +264,18 @@ const getStatus = ({ }): IndicatorStatus => { // a filter is only considered unset if it's value is null const hasValue = label !== null; - if (type === DataMaskType.CrossFilters && hasValue) { - return IndicatorStatus.CrossFilterApplied; - } + const APPLIED_STATUS = + type === DataMaskType.CrossFilters + ? IndicatorStatus.CrossFilterApplied + : IndicatorStatus.Applied; if (!column && hasValue) { // Filter without datasource - return IndicatorStatus.Applied; + return APPLIED_STATUS; } if (column && rejectedColumns?.has(column)) return IndicatorStatus.Incompatible; if (column && appliedColumns?.has(column) && hasValue) { - return IndicatorStatus.Applied; + return APPLIED_STATUS; } return IndicatorStatus.Unset; }; @@ -253,11 +286,12 @@ export const selectChartCrossFilters = ( chartId: number, dashboardLayout: Layout, chartConfiguration: ChartConfiguration = defaultChartConfig, + appliedColumns: Set, + rejectedColumns: Set, filterEmitter = false, ): Indicator[] | CrossFilterIndicator[] => { let crossFilterIndicators: any = []; if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) { - const dashboardLayoutValues = Object.values(dashboardLayout); crossFilterIndicators = Object.values(chartConfiguration) .filter(chartConfig => { const inScope = @@ -271,34 +305,22 @@ export const selectChartCrossFilters = ( return false; }) .map(chartConfig => { - const filterState = dataMask[chartConfig.id]?.filterState; - const extraFormData = dataMask[chartConfig.id]?.extraFormData; - const label = extractLabel(filterState); - const filtersState = filterState?.filters; - const column = - extraFormData?.filters?.[0]?.col || - (filtersState && Object.keys(filtersState)[0]); - - const dashboardLayoutItem = dashboardLayoutValues.find( - layoutItem => layoutItem?.meta?.chartId === chartConfig.id, + const filterIndicator = getCrossFilterIndicator( + chartConfig.id, + dataMask[chartConfig.id], + dashboardLayout, ); - const filterObject: Indicator = { - column, - name: dashboardLayoutItem?.meta?.sliceName as string, - path: [ - ...(dashboardLayoutItem?.parents ?? []), - dashboardLayoutItem?.id || '', - ], - status: getStatus({ - label, - type: DataMaskType.CrossFilters, - }), - value: label, - }; - if (filterEmitter) { - (filterObject as CrossFilterIndicator).emitterId = chartId; - } - return filterObject; + const filterStatus = getStatus({ + label: filterIndicator.value, + column: filterIndicator.column + ? getColumnLabel(filterIndicator.column) + : undefined, + type: DataMaskType.CrossFilters, + appliedColumns, + rejectedColumns, + }); + + return { ...filterIndicator, status: filterStatus }; }) .filter(filter => filter.status === IndicatorStatus.CrossFilterApplied); } @@ -368,6 +390,8 @@ export const selectNativeIndicatorsForChart = ( chartId, dashboardLayout, chartConfiguration, + appliedColumns, + rejectedColumns, ); } const indicators = crossFilterIndicators.concat(nativeFilterIndicators); diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx index 6ece49537b244..7323f120c996f 100644 --- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx +++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx @@ -17,6 +17,7 @@ * under the License. */ import React, { FC, useEffect, useMemo, useRef } from 'react'; +import { Global } from '@emotion/react'; import { useHistory } from 'react-router-dom'; import { CategoricalColorNamespace, @@ -25,6 +26,7 @@ import { isFeatureEnabled, SharedLabelColorSource, t, + useTheme, } from '@superset-ui/core'; import pick from 'lodash/pick'; import { useDispatch, useSelector } from 'react-redux'; @@ -57,6 +59,7 @@ import { DashboardContextForExplore } from 'src/types/DashboardContextForExplore import shortid from 'shortid'; import { RootState } from '../types'; import { getActiveFilters } from '../util/activeDashboardFilters'; +import { filterCardPopoverStyle, headerStyles } from '../styles'; export const DashboardPageIdContext = React.createContext(''); @@ -140,6 +143,7 @@ const useSyncDashboardStateWithLocalStorage = () => { }; export const DashboardPage: FC = ({ idOrSlug }: PageProps) => { + const theme = useTheme(); const dispatch = useDispatch(); const history = useHistory(); const dashboardPageId = useSyncDashboardStateWithLocalStorage(); @@ -274,9 +278,12 @@ export const DashboardPage: FC = ({ idOrSlug }: PageProps) => { if (!readyToRender) return ; return ( - - - + <> + + + + + ); }; diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts index 87dee0b05ab68..56bccf900e84a 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -101,7 +101,6 @@ export type DashboardInfo = { json_metadata: string; metadata: { native_filter_configuration: JsonObject; - show_native_filters: boolean; chart_configuration: JsonObject; color_scheme: string; color_namespace: string; diff --git a/superset-frontend/src/dashboard/util/crossFilters.test.ts b/superset-frontend/src/dashboard/util/crossFilters.test.ts new file mode 100644 index 0000000000000..9dbdac4d241f5 --- /dev/null +++ b/superset-frontend/src/dashboard/util/crossFilters.test.ts @@ -0,0 +1,207 @@ +/** + * 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 sinon from 'sinon'; +import { Behavior, FeatureFlag } from '@superset-ui/core'; +import * as core from '@superset-ui/core'; +import { getCrossFiltersConfiguration } from './crossFilters'; + +const DASHBOARD_LAYOUT = { + 'CHART-1': { + children: [], + id: 'CHART-1', + meta: { + chartId: 1, + sliceName: 'Test chart 1', + height: 1, + width: 1, + uuid: '1', + }, + parents: ['ROOT_ID', 'GRID_ID', 'ROW-6XUMf1rV76'], + type: 'CHART', + }, + 'CHART-2': { + children: [], + id: 'CHART-2', + meta: { + chartId: 2, + sliceName: 'Test chart 2', + height: 1, + width: 1, + uuid: '2', + }, + parents: ['ROOT_ID', 'GRID_ID', 'ROW-6XUMf1rV76'], + type: 'CHART', + }, +}; + +const CHARTS = { + '1': { + id: 1, + form_data: { + datasource: '2__table', + viz_type: 'echarts_timeseries_line', + slice_id: 1, + }, + chartAlert: null, + chartStatus: 'rendered' as const, + chartUpdateEndTime: 0, + chartUpdateStartTime: 0, + lastRendered: 0, + latestQueryFormData: {}, + sliceFormData: { + datasource: '2__table', + viz_type: 'echarts_timeseries_line', + }, + queryController: null, + queriesResponse: [{}], + triggerQuery: false, + }, + '2': { + id: 2, + form_data: { + datasource: '2__table', + viz_type: 'echarts_timeseries_line', + slice_id: 2, + }, + chartAlert: null, + chartStatus: 'rendered' as const, + chartUpdateEndTime: 0, + chartUpdateStartTime: 0, + lastRendered: 0, + latestQueryFormData: {}, + sliceFormData: { + datasource: '2__table', + viz_type: 'echarts_timeseries_line', + }, + queryController: null, + queriesResponse: [{}], + triggerQuery: false, + }, +}; + +const INITIAL_CHART_CONFIG = { + '1': { + id: 1, + crossFilters: { + scope: { + rootPath: ['ROOT_ID'], + excluded: [1], + }, + chartsInScope: [2], + }, + }, +}; + +test('Generate correct cross filters configuration without initial configuration', () => { + // @ts-ignore + global.featureFlags = { + [FeatureFlag.DASHBOARD_CROSS_FILTERS]: true, + }; + const metadataRegistryStub = sinon + .stub(core, 'getChartMetadataRegistry') + .callsFake(() => ({ + // @ts-ignore + get: () => ({ + behaviors: [Behavior.INTERACTIVE_CHART], + }), + })); + expect( + getCrossFiltersConfiguration(DASHBOARD_LAYOUT, undefined, CHARTS), + ).toEqual({ + '1': { + id: 1, + crossFilters: { + scope: { + rootPath: ['ROOT_ID'], + excluded: [1], + }, + chartsInScope: [2], + }, + }, + '2': { + id: 2, + crossFilters: { + scope: { + rootPath: ['ROOT_ID'], + excluded: [2], + }, + chartsInScope: [1], + }, + }, + }); + metadataRegistryStub.restore(); +}); + +test('Generate correct cross filters configuration with initial configuration', () => { + // @ts-ignore + global.featureFlags = { + [FeatureFlag.DASHBOARD_CROSS_FILTERS]: true, + }; + const metadataRegistryStub = sinon + .stub(core, 'getChartMetadataRegistry') + .callsFake(() => ({ + // @ts-ignore + get: () => ({ + behaviors: [Behavior.INTERACTIVE_CHART], + }), + })); + expect( + getCrossFiltersConfiguration( + DASHBOARD_LAYOUT, + INITIAL_CHART_CONFIG, + CHARTS, + ), + ).toEqual({ + '1': { + id: 1, + crossFilters: { + scope: { + rootPath: ['ROOT_ID'], + excluded: [1], + }, + chartsInScope: [2], + }, + }, + '2': { + id: 2, + crossFilters: { + scope: { + rootPath: ['ROOT_ID'], + excluded: [2], + }, + chartsInScope: [1], + }, + }, + }); + metadataRegistryStub.restore(); +}); + +test('Return undefined if DASHBOARD_CROSS_FILTERS feature flag is disabled', () => { + // @ts-ignore + global.featureFlags = { + [FeatureFlag.DASHBOARD_CROSS_FILTERS]: false, + }; + expect( + getCrossFiltersConfiguration( + DASHBOARD_LAYOUT, + INITIAL_CHART_CONFIG, + CHARTS, + ), + ).toEqual(undefined); +}); diff --git a/superset-frontend/src/dashboard/util/crossFilters.ts b/superset-frontend/src/dashboard/util/crossFilters.ts index 061ed82634ac9..862db89798f81 100644 --- a/superset-frontend/src/dashboard/util/crossFilters.ts +++ b/superset-frontend/src/dashboard/util/crossFilters.ts @@ -17,10 +17,69 @@ * under the License. */ -import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; +import { + Behavior, + FeatureFlag, + getChartMetadataRegistry, + isDefined, + isFeatureEnabled, +} from '@superset-ui/core'; +import { DASHBOARD_ROOT_ID } from './constants'; +import { getChartIdsInFilterScope } from './getChartIdsInFilterScope'; +import { ChartsState, DashboardInfo, DashboardLayout } from '../types'; export const isCrossFiltersEnabled = ( metadataCrossFiltersEnabled: boolean | undefined, ): boolean => isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS) && (metadataCrossFiltersEnabled === undefined || metadataCrossFiltersEnabled); + +export const getCrossFiltersConfiguration = ( + dashboardLayout: DashboardLayout, + initialConfig: DashboardInfo['metadata']['chart_configuration'] = {}, + charts: ChartsState, +) => { + if (!isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) { + return undefined; + } + // If user just added cross filter to dashboard it's not saving it scope on server, + // so we tweak it until user will update scope and will save it in server + const chartConfiguration = {}; + Object.values(dashboardLayout).forEach(layoutItem => { + const chartId = layoutItem.meta?.chartId; + + if (!isDefined(chartId)) { + return; + } + + const behaviors = + ( + getChartMetadataRegistry().get(charts[chartId]?.form_data?.viz_type) ?? + {} + )?.behaviors ?? []; + + if (behaviors.includes(Behavior.INTERACTIVE_CHART)) { + if (initialConfig[chartId]) { + chartConfiguration[chartId] = initialConfig[chartId]; + } + if (!chartConfiguration[chartId]) { + chartConfiguration[chartId] = { + id: chartId, + crossFilters: { + scope: { + rootPath: [DASHBOARD_ROOT_ID], + excluded: [chartId], // By default it doesn't affects itself + }, + }, + }; + } + chartConfiguration[chartId].crossFilters.chartsInScope = + getChartIdsInFilterScope( + chartConfiguration[chartId].crossFilters.scope, + charts, + dashboardLayout, + ); + } + }); + return chartConfiguration; +}; diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx index 2be8745fa1936..333d3ec799c22 100644 --- a/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx +++ b/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx @@ -17,6 +17,7 @@ * under the License. */ import React from 'react'; +import userEvent from '@testing-library/user-event'; import { render, screen } from 'spec/helpers/testing-library'; import { DatasourceType, @@ -104,5 +105,43 @@ describe('ControlPanelsContainer', () => { expect( await screen.findAllByTestId('collapsible-control-panel-header'), ).toHaveLength(4); + expect(screen.getByRole('tab', { name: /customize/i })).toBeInTheDocument(); + userEvent.click(screen.getByRole('tab', { name: /customize/i })); + expect( + await screen.findAllByTestId('collapsible-control-panel-header'), + ).toHaveLength(5); + }); + + test('renders ControlPanelSections no Customize Tab', async () => { + getChartControlPanelRegistry().registerValue('table', { + controlPanelSections: [ + { + label: t('GROUP BY'), + description: t( + 'Use this section if you want a query that aggregates', + ), + expanded: true, + controlSetRows: [ + ['groupby'], + ['metrics'], + ['percent_metrics'], + ['timeseries_limit_metric', 'row_limit'], + ['include_time', 'order_desc'], + ], + }, + { + label: t('Options'), + expanded: true, + controlSetRows: [], + }, + ], + }); + render(, { + useRedux: true, + }); + expect(screen.queryByText(/customize/i)).not.toBeInTheDocument(); + expect( + await screen.findAllByTestId('collapsible-control-panel-header'), + ).toHaveLength(2); }); }); diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx index 60aac1948bf4e..8a456619f4acf 100644 --- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx +++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx @@ -70,6 +70,7 @@ import Control from './Control'; import { ExploreAlert } from './ExploreAlert'; import { RunQueryButton } from './RunQueryButton'; import { Operators } from '../constants'; +import { CLAUSES } from './controls/FilterControl/types'; const { confirm } = Modal; @@ -235,7 +236,7 @@ function getState( ) ) { querySections.push(section); - } else { + } else if (section.controlSetRows.length > 0) { customizeSections.push(section); } }); @@ -317,7 +318,7 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { setControlValue('adhoc_filters', [ ...(adhoc_filters || []), { - clause: 'WHERE', + clause: CLAUSES.WHERE, subject: x_axis, operator: Operators.TEMPORAL_RANGE, comparator: defaultTimeFilter || NO_TIME_RANGE, diff --git a/superset-frontend/src/explore/components/SaveModal.tsx b/superset-frontend/src/explore/components/SaveModal.tsx index d62cb4d898c6c..8d7bd2d895547 100644 --- a/superset-frontend/src/explore/components/SaveModal.tsx +++ b/superset-frontend/src/explore/components/SaveModal.tsx @@ -41,6 +41,7 @@ import { Select } from 'src/components'; import Loading from 'src/components/Loading'; import { setSaveChartModalVisibility } from 'src/explore/actions/saveModalActions'; import { SaveActionType } from 'src/explore/types'; +import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; // Session storage key for recent dashboard const SK_DASHBOARD_ID = 'save_chart_recent_dashboard'; @@ -69,6 +70,7 @@ type SaveModalState = { action: SaveActionType; isLoading: boolean; saveStatus?: string | null; + vizType?: string; }; export const StyledModal = styled(Modal)` @@ -92,6 +94,7 @@ class SaveModal extends React.Component { alert: null, action: this.canOverwriteSlice() ? 'overwrite' : 'saveas', isLoading: false, + vizType: props.form_data?.viz_type, }; this.onDashboardSelectChange = this.onDashboardSelectChange.bind(this); this.onSliceNameChange = this.onSliceNameChange.bind(this); @@ -339,27 +342,32 @@ class SaveModal extends React.Component { /> )} - - + {t('Select')} + {t(' a dashboard OR ')} + {t('create')} + {t(' a new one')} +
+ } + /> + + )} ); }; @@ -376,7 +384,9 @@ class SaveModal extends React.Component { !this.state.newSliceName || (!this.state.saveToDashboardId && !this.state.newDashboardName) || (this.props.datasource?.type !== DatasourceType.Table && - !this.state.datasetName) + !this.state.datasetName) || + (isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) && + this.state.vizType === 'filter_box') } onClick={() => this.saveOrOverwrite(true)} > diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.test.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.test.tsx index b6f6c6fb9c2f5..5bf79a3b83173 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.test.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.test.tsx @@ -32,14 +32,13 @@ import { TimeseriesDefaultFormData } from '@superset-ui/plugin-chart-echarts'; import { render, screen } from 'spec/helpers/testing-library'; import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric'; -import AdhocFilter, { - EXPRESSION_TYPES, -} from 'src/explore/components/controls/FilterControl/AdhocFilter'; +import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter'; import { DndFilterSelect, DndFilterSelectProps, } from 'src/explore/components/controls/DndColumnSelectControl/DndFilterSelect'; import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants'; +import { EXPRESSION_TYPES } from '../FilterControl/types'; const defaultProps: DndFilterSelectProps = { type: 'DndFilterSelect', diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx index 134678aa6fad9..b7d5232c20130 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx @@ -43,10 +43,7 @@ import { Datasource, OptionSortType } from 'src/explore/types'; import { OptionValueType } from 'src/explore/components/controls/DndColumnSelectControl/types'; import AdhocFilterPopoverTrigger from 'src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger'; import DndSelectLabel from 'src/explore/components/controls/DndColumnSelectControl/DndSelectLabel'; -import AdhocFilter, { - CLAUSES, - EXPRESSION_TYPES, -} from 'src/explore/components/controls/FilterControl/AdhocFilter'; +import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter'; import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric'; import { DatasourcePanelDndItem, @@ -58,6 +55,7 @@ import { ControlComponentProps } from 'src/explore/components/Control'; import AdhocFilterControl from '../FilterControl/AdhocFilterControl'; import DndAdhocFilterOption from './DndAdhocFilterOption'; import { useDefaultTimeFilter } from '../DateFilterControl/utils'; +import { CLAUSES, EXPRESSION_TYPES } from '../FilterControl/types'; const { warning } = Modal; diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/AdhocFilter.test.js b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/AdhocFilter.test.js index f3e08d7a28ae3..20f9576a47aa2 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/AdhocFilter.test.js +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/AdhocFilter.test.js @@ -16,11 +16,9 @@ * specific language governing permissions and limitations * under the License. */ -import AdhocFilter, { - EXPRESSION_TYPES, - CLAUSES, -} from 'src/explore/components/controls/FilterControl/AdhocFilter'; +import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter'; import { Operators } from 'src/explore/constants'; +import { EXPRESSION_TYPES, CLAUSES } from '../types'; describe('AdhocFilter', () => { it('sets filterOptionName in constructor', () => { diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/index.js b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/index.js index 3b54a7dc1a7aa..f00491b89fa53 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/index.js +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/index.js @@ -21,60 +21,13 @@ import { Operators, OPERATOR_ENUM_TO_OPERATOR_TYPE, } from 'src/explore/constants'; -import { getSimpleSQLExpression } from 'src/explore/exploreUtils'; - -export const EXPRESSION_TYPES = { - SIMPLE: 'SIMPLE', - SQL: 'SQL', -}; - -export const CLAUSES = { - HAVING: 'HAVING', - WHERE: 'WHERE', -}; - -const OPERATORS_TO_SQL = { - '==': '=', - '!=': '<>', - '>': '>', - '<': '<', - '>=': '>=', - '<=': '<=', - IN: 'IN', - 'NOT IN': 'NOT IN', - LIKE: 'LIKE', - ILIKE: 'ILIKE', - REGEX: 'REGEX', - 'IS NOT NULL': 'IS NOT NULL', - 'IS NULL': 'IS NULL', - 'IS TRUE': 'IS TRUE', - 'IS FALSE': 'IS FALSE', - 'LATEST PARTITION': ({ datasource }) => - `= '{{ presto.latest_partition('${datasource.schema}.${datasource.datasource_name}') }}'`, -}; +import { translateToSql } from '../utils/translateToSQL'; +import { CLAUSES, EXPRESSION_TYPES } from '../types'; const CUSTOM_OPERATIONS = [...CUSTOM_OPERATORS].map( op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation, ); -function translateToSql(adhocMetric, { useSimple } = {}) { - if (adhocMetric.expressionType === EXPRESSION_TYPES.SIMPLE || useSimple) { - const { subject, comparator } = adhocMetric; - const operator = - adhocMetric.operator && - // 'LATEST PARTITION' supported callback only - adhocMetric.operator === - OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.LATEST_PARTITION].operation - ? OPERATORS_TO_SQL[adhocMetric.operator](adhocMetric) - : OPERATORS_TO_SQL[adhocMetric.operator]; - return getSimpleSQLExpression(subject, operator, comparator); - } - if (adhocMetric.expressionType === EXPRESSION_TYPES.SQL) { - return adhocMetric.sqlExpression; - } - return ''; -} - export default class AdhocFilter { constructor(adhocFilter) { this.expressionType = adhocFilter.expressionType || EXPRESSION_TYPES.SIMPLE; diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/AdhocFilterControl.test.jsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/AdhocFilterControl.test.jsx index c7b13d55fa95a..964f0def000ce 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/AdhocFilterControl.test.jsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/AdhocFilterControl.test.jsx @@ -22,10 +22,7 @@ import sinon from 'sinon'; import { shallow } from 'enzyme'; import { supersetTheme } from '@superset-ui/core'; -import AdhocFilter, { - EXPRESSION_TYPES, - CLAUSES, -} from 'src/explore/components/controls/FilterControl/AdhocFilter'; +import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter'; import { LabelsContainer } from 'src/explore/components/controls/OptionControls'; import { AGGREGATES, @@ -34,6 +31,7 @@ import { } from 'src/explore/constants'; import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric'; import AdhocFilterControl from '.'; +import { CLAUSES, EXPRESSION_TYPES } from '../types'; const simpleAdhocFilter = new AdhocFilter({ expressionType: EXPRESSION_TYPES.SIMPLE, diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.jsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.jsx index c0750c7805d00..c1a95bc8f2dc5 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.jsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.jsx @@ -45,12 +45,10 @@ import Icons from 'src/components/Icons'; import Modal from 'src/components/Modal'; import AdhocFilterPopoverTrigger from 'src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger'; import AdhocFilterOption from 'src/explore/components/controls/FilterControl/AdhocFilterOption'; -import AdhocFilter, { - CLAUSES, - EXPRESSION_TYPES, -} from 'src/explore/components/controls/FilterControl/AdhocFilter'; +import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter'; import adhocFilterType from 'src/explore/components/controls/FilterControl/adhocFilterType'; import columnType from 'src/explore/components/controls/FilterControl/columnType'; +import { CLAUSES, EXPRESSION_TYPES } from '../types'; const { warning } = Modal; diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/AdhocFilterEditPopover.test.jsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/AdhocFilterEditPopover.test.jsx index 3fec073f94660..d81abdc0796a6 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/AdhocFilterEditPopover.test.jsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/AdhocFilterEditPopover.test.jsx @@ -24,15 +24,13 @@ import Button from 'src/components/Button'; import ErrorBoundary from 'src/components/ErrorBoundary'; import Tabs from 'src/components/Tabs'; -import AdhocFilter, { - EXPRESSION_TYPES, - CLAUSES, -} from 'src/explore/components/controls/FilterControl/AdhocFilter'; +import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter'; import { AGGREGATES } from 'src/explore/constants'; import AdhocFilterEditPopoverSimpleTabContent from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent'; import AdhocFilterEditPopoverSqlTabContent from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSqlTabContent'; import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric'; import AdhocFilterEditPopover from '.'; +import { CLAUSES, EXPRESSION_TYPES } from '../types'; const simpleAdhocFilter = new AdhocFilter({ expressionType: EXPRESSION_TYPES.SIMPLE, diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.jsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.jsx index 98c6da8f1c004..7820ceb9d4e2f 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.jsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.jsx @@ -24,9 +24,7 @@ import { styled, t } from '@superset-ui/core'; import ErrorBoundary from 'src/components/ErrorBoundary'; import Tabs from 'src/components/Tabs'; import adhocMetricType from 'src/explore/components/controls/MetricControl/adhocMetricType'; -import AdhocFilter, { - EXPRESSION_TYPES, -} from 'src/explore/components/controls/FilterControl/AdhocFilter'; +import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter'; import AdhocFilterEditPopoverSimpleTabContent from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent'; import AdhocFilterEditPopoverSqlTabContent from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSqlTabContent'; import columnType from 'src/explore/components/controls/FilterControl/columnType'; @@ -34,6 +32,7 @@ import { POPOVER_INITIAL_HEIGHT, POPOVER_INITIAL_WIDTH, } from 'src/explore/constants'; +import { EXPRESSION_TYPES } from '../types'; const propTypes = { adhocFilter: PropTypes.instanceOf(AdhocFilter).isRequired, diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/AdhocFilterEditPopoverSimpleTabContent.test.tsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/AdhocFilterEditPopoverSimpleTabContent.test.tsx index 9d1b68bc6f75d..9ad90167b598a 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/AdhocFilterEditPopoverSimpleTabContent.test.tsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/AdhocFilterEditPopoverSimpleTabContent.test.tsx @@ -25,10 +25,7 @@ import thunk from 'redux-thunk'; import { Provider } from 'react-redux'; import configureStore from 'redux-mock-store'; -import AdhocFilter, { - EXPRESSION_TYPES, - CLAUSES, -} from 'src/explore/components/controls/FilterControl/AdhocFilter'; +import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter'; import { AGGREGATES, Operators, @@ -46,6 +43,7 @@ import AdhocFilterEditPopoverSimpleTabContent, { useSimpleTabFilterProps, Props, } from '.'; +import { CLAUSES, EXPRESSION_TYPES } from '../types'; const simpleAdhocFilter = new AdhocFilter({ expressionType: EXPRESSION_TYPES.SIMPLE, 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 f0b05d8f1f6fe..84feb448f668b 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/index.tsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/index.tsx @@ -38,10 +38,7 @@ import { OPERATOR_ENUM_TO_OPERATOR_TYPE, } from 'src/explore/constants'; import FilterDefinitionOption from 'src/explore/components/controls/MetricControl/FilterDefinitionOption'; -import AdhocFilter, { - EXPRESSION_TYPES, - CLAUSES, -} from 'src/explore/components/controls/FilterControl/AdhocFilter'; +import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter'; import { Tooltip } from 'src/components/Tooltip'; import { Input } from 'src/components/Input'; import { optionLabel } from 'src/utils/common'; @@ -54,6 +51,7 @@ import { import useAdvancedDataTypes from './useAdvancedDataTypes'; import { useDatePickerInAdhocFilter } from '../utils'; import { useDefaultTimeFilter } from '../../DateFilterControl/utils'; +import { CLAUSES, EXPRESSION_TYPES } from '../types'; const StyledInput = styled(Input)` margin-bottom: ${({ theme }) => theme.gridUnit * 4}px; diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSqlTabContent/AdhocFilterEditPopoverSqlTabContent.test.jsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSqlTabContent/AdhocFilterEditPopoverSqlTabContent.test.jsx index 00969225f62a4..c0837fdb486ac 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSqlTabContent/AdhocFilterEditPopoverSqlTabContent.test.jsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSqlTabContent/AdhocFilterEditPopoverSqlTabContent.test.jsx @@ -21,11 +21,9 @@ import React from 'react'; import sinon from 'sinon'; import { shallow } from 'enzyme'; -import AdhocFilter, { - EXPRESSION_TYPES, - CLAUSES, -} from 'src/explore/components/controls/FilterControl/AdhocFilter'; +import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter'; import AdhocFilterEditPopoverSqlTabContent from '.'; +import { CLAUSES, EXPRESSION_TYPES } from '../types'; const sqlAdhocFilter = new AdhocFilter({ expressionType: EXPRESSION_TYPES.SQL, diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSqlTabContent/index.jsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSqlTabContent/index.jsx index 2c897c0994a28..2e46e072555a9 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSqlTabContent/index.jsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSqlTabContent/index.jsx @@ -25,10 +25,8 @@ import sqlKeywords from 'src/SqlLab/utils/sqlKeywords'; import adhocMetricType from 'src/explore/components/controls/MetricControl/adhocMetricType'; import columnType from 'src/explore/components/controls/FilterControl/columnType'; -import AdhocFilter, { - EXPRESSION_TYPES, - CLAUSES, -} from 'src/explore/components/controls/FilterControl/AdhocFilter'; +import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter'; +import { CLAUSES, EXPRESSION_TYPES } from '../types'; const propTypes = { adhocFilter: PropTypes.instanceOf(AdhocFilter).isRequired, diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterOption/AdhocFilterOption.test.tsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterOption/AdhocFilterOption.test.tsx index 8513f452e58d8..514a10094d51f 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterOption/AdhocFilterOption.test.tsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterOption/AdhocFilterOption.test.tsx @@ -19,11 +19,9 @@ import React from 'react'; import { render, screen, waitFor } from 'spec/helpers/testing-library'; import userEvent from '@testing-library/user-event'; -import AdhocFilter, { - EXPRESSION_TYPES, - CLAUSES, -} from 'src/explore/components/controls/FilterControl/AdhocFilter'; +import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter'; import AdhocFilterOption, { AdhocFilterOptionProps } from '.'; +import { CLAUSES, EXPRESSION_TYPES } from '../types'; const simpleAdhocFilter = new AdhocFilter({ expressionType: EXPRESSION_TYPES.SIMPLE, diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/AdhocFilterPopoverTrigger.test.tsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/AdhocFilterPopoverTrigger.test.tsx index 36c711ebf048b..b983c413eade0 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/AdhocFilterPopoverTrigger.test.tsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/AdhocFilterPopoverTrigger.test.tsx @@ -19,11 +19,9 @@ import React from 'react'; import { render, screen } from 'spec/helpers/testing-library'; import userEvent from '@testing-library/user-event'; -import AdhocFilter, { - EXPRESSION_TYPES, - CLAUSES, -} from 'src/explore/components/controls/FilterControl/AdhocFilter'; +import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter'; import AdhocFilterPopoverTrigger from '.'; +import { CLAUSES, EXPRESSION_TYPES } from '../types'; const simpleAdhocFilter = new AdhocFilter({ expressionType: EXPRESSION_TYPES.SIMPLE, diff --git a/superset-frontend/src/explore/components/controls/FilterControl/adhocFilterType.js b/superset-frontend/src/explore/components/controls/FilterControl/adhocFilterType.js index c9eef05926fc9..df9f43a5d6645 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/adhocFilterType.js +++ b/superset-frontend/src/explore/components/controls/FilterControl/adhocFilterType.js @@ -17,7 +17,7 @@ * under the License. */ import PropTypes from 'prop-types'; -import { EXPRESSION_TYPES, CLAUSES } from './AdhocFilter'; +import { CLAUSES, EXPRESSION_TYPES } from './types'; export default PropTypes.oneOfType([ PropTypes.shape({ diff --git a/superset-frontend/src/dashboard/components/CrossFilterScopingModal/types.ts b/superset-frontend/src/explore/components/controls/FilterControl/types.ts similarity index 85% rename from superset-frontend/src/dashboard/components/CrossFilterScopingModal/types.ts rename to superset-frontend/src/explore/components/controls/FilterControl/types.ts index 6dfd11f95bb54..4adceab2173d8 100644 --- a/superset-frontend/src/dashboard/components/CrossFilterScopingModal/types.ts +++ b/superset-frontend/src/explore/components/controls/FilterControl/types.ts @@ -17,8 +17,12 @@ * under the License. */ -import { NativeFilterScope } from '@superset-ui/core'; +export enum EXPRESSION_TYPES { + SIMPLE = 'SIMPLE', + SQL = 'SQL', +} -export type CrossFilterScopingFormType = { - scope: NativeFilterScope; -}; +export enum CLAUSES { + HAVING = 'HAVING', + WHERE = 'WHERE', +} diff --git a/superset-frontend/src/explore/components/controls/FilterControl/utils/translateToSQL.ts b/superset-frontend/src/explore/components/controls/FilterControl/utils/translateToSQL.ts new file mode 100644 index 0000000000000..e041a30fbabc4 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/FilterControl/utils/translateToSQL.ts @@ -0,0 +1,76 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + AdhocFilter, + isFreeFormAdhocFilter, + isSimpleAdhocFilter, + SimpleAdhocFilter, +} from '@superset-ui/core'; +import { + OPERATOR_ENUM_TO_OPERATOR_TYPE, + Operators, +} from 'src/explore/constants'; +import { getSimpleSQLExpression } from 'src/explore/exploreUtils'; + +export const OPERATORS_TO_SQL = { + '==': '=', + '!=': '<>', + '>': '>', + '<': '<', + '>=': '>=', + '<=': '<=', + IN: 'IN', + 'NOT IN': 'NOT IN', + LIKE: 'LIKE', + ILIKE: 'ILIKE', + REGEX: 'REGEX', + 'IS NOT NULL': 'IS NOT NULL', + 'IS NULL': 'IS NULL', + 'IS TRUE': 'IS TRUE', + 'IS FALSE': 'IS FALSE', + 'LATEST PARTITION': ({ + datasource, + }: { + datasource: { schema: string; datasource_name: string }; + }) => + `= '{{ presto.latest_partition('${datasource.schema}.${datasource.datasource_name}') }}'`, +}; + +export const translateToSql = ( + adhocFilter: AdhocFilter, + { useSimple }: { useSimple: boolean } = { useSimple: false }, +) => { + if (isSimpleAdhocFilter(adhocFilter) || useSimple) { + const { subject, operator } = adhocFilter as SimpleAdhocFilter; + const comparator = + 'comparator' in adhocFilter ? adhocFilter.comparator : undefined; + const op = + operator && + // 'LATEST PARTITION' supported callback only + operator === + OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.LATEST_PARTITION].operation + ? OPERATORS_TO_SQL[operator](adhocFilter) + : OPERATORS_TO_SQL[operator]; + return getSimpleSQLExpression(subject, op, comparator); + } + if (isFreeFormAdhocFilter(adhocFilter)) { + return adhocFilter.sqlExpression; + } + return ''; +}; diff --git a/superset-frontend/src/explore/components/controls/FilterControl/utils/useGetTimeRangeLabel.test.ts b/superset-frontend/src/explore/components/controls/FilterControl/utils/useGetTimeRangeLabel.test.ts index 0d39ef8a27041..4deb8fbc6ab17 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/utils/useGetTimeRangeLabel.test.ts +++ b/superset-frontend/src/explore/components/controls/FilterControl/utils/useGetTimeRangeLabel.test.ts @@ -21,7 +21,8 @@ import { NO_TIME_RANGE } from '@superset-ui/core'; import { Operators } from 'src/explore/constants'; import * as FetchTimeRangeModule from 'src/explore/components/controls/DateFilterControl'; import { useGetTimeRangeLabel } from './useGetTimeRangeLabel'; -import AdhocFilter, { CLAUSES, EXPRESSION_TYPES } from '../AdhocFilter'; +import AdhocFilter from '../AdhocFilter'; +import { CLAUSES, EXPRESSION_TYPES } from '../types'; test('should return empty object if operator is not TEMPORAL_RANGE', () => { const adhocFilter = new AdhocFilter({ diff --git a/superset-frontend/src/explore/components/controls/FilterControl/utils/useGetTimeRangeLabel.tsx b/superset-frontend/src/explore/components/controls/FilterControl/utils/useGetTimeRangeLabel.tsx index abc2ad5b27c91..f06743ed5cba2 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/utils/useGetTimeRangeLabel.tsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/utils/useGetTimeRangeLabel.tsx @@ -20,7 +20,8 @@ import { useEffect, useState } from 'react'; import { NO_TIME_RANGE } from '@superset-ui/core'; import { fetchTimeRange } from 'src/explore/components/controls/DateFilterControl'; import { Operators } from 'src/explore/constants'; -import AdhocFilter, { EXPRESSION_TYPES } from '../AdhocFilter'; +import AdhocFilter from '../AdhocFilter'; +import { EXPRESSION_TYPES } from '../types'; interface Results { actualTimeRange?: string; diff --git a/superset-frontend/src/explore/controlUtils/getFormDataFromDashboardContext.test.ts b/superset-frontend/src/explore/controlUtils/getFormDataFromDashboardContext.test.ts index f3584e1c70924..ca10ae1193c0b 100644 --- a/superset-frontend/src/explore/controlUtils/getFormDataFromDashboardContext.test.ts +++ b/superset-frontend/src/explore/controlUtils/getFormDataFromDashboardContext.test.ts @@ -129,6 +129,15 @@ const getDashboardFormData = (overrides: JsonObject = {}) => ({ op: '<=', val: 10000, }, + { + col: { + sqlExpression: 'totally viable sql expression', + expressionType: 'SQL', + label: 'My column', + }, + op: 'IN', + val: ['Value1', 'Value2'], + }, ], granularity_sqla: 'ds', time_range: 'Last month', @@ -179,6 +188,7 @@ const getExpectedResultFormData = (overrides: JsonObject = {}) => ({ clause: 'WHERE', expressionType: 'SIMPLE', operator: 'IN', + operatorId: 'IN', subject: 'name', comparator: ['Aaron'], isExtra: true, @@ -188,11 +198,19 @@ const getExpectedResultFormData = (overrides: JsonObject = {}) => ({ clause: 'WHERE', expressionType: 'SIMPLE', operator: '<=', + operatorId: 'LESS_THAN_OR_EQUAL', subject: 'num_boys', comparator: 10000, isExtra: true, filterOptionName: expect.any(String), }, + { + clause: 'WHERE', + expressionType: 'SQL', + sqlExpression: `(totally viable sql expression) IN ('Value1', 'Value2')`, + filterOptionName: expect.any(String), + isExtra: true, + }, ], adhoc_filters_b: [ { @@ -208,6 +226,7 @@ const getExpectedResultFormData = (overrides: JsonObject = {}) => ({ clause: 'WHERE', expressionType: 'SIMPLE', operator: 'IN', + operatorId: 'IN', subject: 'name', comparator: ['Aaron'], isExtra: true, @@ -217,11 +236,19 @@ const getExpectedResultFormData = (overrides: JsonObject = {}) => ({ clause: 'WHERE', expressionType: 'SIMPLE', operator: '<=', + operatorId: 'LESS_THAN_OR_EQUAL', subject: 'num_boys', comparator: 10000, isExtra: true, filterOptionName: expect.any(String), }, + { + clause: 'WHERE', + expressionType: 'SQL', + sqlExpression: `(totally viable sql expression) IN ('Value1', 'Value2')`, + filterOptionName: expect.any(String), + isExtra: true, + }, ], applied_time_extras: { __time_grain: 'P1D', @@ -282,6 +309,15 @@ const getExpectedResultFormData = (overrides: JsonObject = {}) => ({ op: '<=', val: 10000, }, + { + col: { + expressionType: 'SQL', + label: 'My column', + sqlExpression: 'totally viable sql expression', + }, + op: 'IN', + val: ['Value1', 'Value2'], + }, ], granularity_sqla: 'ds', time_range: 'Last month', diff --git a/superset-frontend/src/explore/controlUtils/getFormDataWithDashboardContext.ts b/superset-frontend/src/explore/controlUtils/getFormDataWithDashboardContext.ts index 9565b48ddf461..d686b700131eb 100644 --- a/superset-frontend/src/explore/controlUtils/getFormDataWithDashboardContext.ts +++ b/superset-frontend/src/explore/controlUtils/getFormDataWithDashboardContext.ts @@ -18,31 +18,55 @@ */ import isEqual from 'lodash/isEqual'; import { - EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS, + AdhocFilter, + ensureIsArray, EXTRA_FORM_DATA_OVERRIDE_EXTRA_KEYS, + EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS, + isAdhocColumn, isDefined, - JsonObject, - ensureIsArray, - QueryObjectFilterClause, - SimpleAdhocFilter, - QueryFormData, - AdhocFilter, isFreeFormAdhocFilter, isSimpleAdhocFilter, + JsonObject, NO_TIME_RANGE, + QueryFormData, + QueryObjectFilterClause, + SimpleAdhocFilter, } from '@superset-ui/core'; +import { OPERATOR_ENUM_TO_OPERATOR_TYPE } from '../constants'; +import { translateToSql } from '../components/controls/FilterControl/utils/translateToSQL'; +import { + CLAUSES, + EXPRESSION_TYPES, +} from '../components/controls/FilterControl/types'; const simpleFilterToAdhoc = ( filterClause: QueryObjectFilterClause, - clause = 'where', + clause: CLAUSES = CLAUSES.WHERE, ) => { - const result = { - clause: clause.toUpperCase(), - expressionType: 'SIMPLE', - operator: filterClause.op, - subject: filterClause.col, - comparator: 'val' in filterClause ? filterClause.val : undefined, - } as SimpleAdhocFilter; + let result: AdhocFilter; + if (isAdhocColumn(filterClause.col)) { + result = { + expressionType: 'SQL', + clause, + sqlExpression: translateToSql({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: `(${filterClause.col.sqlExpression})`, + operator: filterClause.op, + comparator: 'val' in filterClause ? filterClause.val : undefined, + } as SimpleAdhocFilter), + }; + } else { + result = { + expressionType: 'SIMPLE', + clause, + operator: filterClause.op, + operatorId: Object.entries(OPERATOR_ENUM_TO_OPERATOR_TYPE).find( + operatorEntry => operatorEntry[1].operation === filterClause.op, + )?.[0], + subject: filterClause.col, + comparator: 'val' in filterClause ? filterClause.val : undefined, + } as SimpleAdhocFilter; + } if (filterClause.isExtra) { Object.assign(result, { isExtra: true, diff --git a/superset-frontend/src/filters/utils.ts b/superset-frontend/src/filters/utils.ts index 4908f1a2893c5..69ca82f58ef19 100644 --- a/superset-frontend/src/filters/utils.ts +++ b/superset-frontend/src/filters/utils.ts @@ -25,6 +25,10 @@ import { ExtraFormData, } from '@superset-ui/core'; import { FALSE_STRING, NULL_STRING, TRUE_STRING } from 'src/utils/common'; +import { + CLAUSES, + EXPRESSION_TYPES, +} from '../explore/components/controls/FilterControl/types'; export const getSelectExtraFormData = ( col: string, @@ -36,8 +40,8 @@ export const getSelectExtraFormData = ( if (emptyFilter) { extra.adhoc_filters = [ { - expressionType: 'SQL', - clause: 'WHERE', + expressionType: EXPRESSION_TYPES.SQL, + clause: CLAUSES.WHERE, sqlExpression: '1 = 0', }, ]; diff --git a/superset-frontend/src/views/CRUD/alert/AlertList.test.jsx b/superset-frontend/src/pages/AlertReportList/AlertReportList.test.jsx similarity index 98% rename from superset-frontend/src/views/CRUD/alert/AlertList.test.jsx rename to superset-frontend/src/pages/AlertReportList/AlertReportList.test.jsx index d5b4d241502c0..492b63aa34189 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertList.test.jsx +++ b/superset-frontend/src/pages/AlertReportList/AlertReportList.test.jsx @@ -26,7 +26,7 @@ import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; import { Switch } from 'src/components/Switch'; import ListView from 'src/components/ListView'; import SubMenu from 'src/views/components/SubMenu'; -import AlertList from 'src/views/CRUD/alert/AlertList'; +import AlertList from 'src/pages/AlertReportList'; import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; import { act } from 'react-dom/test-utils'; diff --git a/superset-frontend/src/views/CRUD/alert/AlertList.tsx b/superset-frontend/src/pages/AlertReportList/index.tsx similarity index 99% rename from superset-frontend/src/views/CRUD/alert/AlertList.tsx rename to superset-frontend/src/pages/AlertReportList/index.tsx index 1c34167652460..c4a35435a09fe 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertList.tsx +++ b/superset-frontend/src/pages/AlertReportList/index.tsx @@ -52,8 +52,8 @@ import { import { createErrorHandler, createFetchRelated } from 'src/views/CRUD/utils'; import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; import Owner from 'src/types/Owner'; -import AlertReportModal from './AlertReportModal'; -import { AlertObject, AlertState } from './types'; +import AlertReportModal from 'src/views/CRUD/alert/AlertReportModal'; +import { AlertObject, AlertState } from 'src/views/CRUD/alert/types'; const extensionsRegistry = getExtensionsRegistry(); diff --git a/superset-frontend/src/views/CRUD/allentities/AllEntities.tsx b/superset-frontend/src/pages/AllEntities/index.tsx similarity index 97% rename from superset-frontend/src/views/CRUD/allentities/AllEntities.tsx rename to superset-frontend/src/pages/AllEntities/index.tsx index 995c12734cf41..de9df56e9f4dc 100644 --- a/superset-frontend/src/views/CRUD/allentities/AllEntities.tsx +++ b/superset-frontend/src/pages/AllEntities/index.tsx @@ -24,7 +24,7 @@ import AsyncSelect from 'src/components/Select/AsyncSelect'; import { SelectValue } from 'antd/lib/select'; import { loadTags } from 'src/components/Tags/utils'; import { getValue } from 'src/components/Select/utils'; -import AllEntitiesTable from './AllEntitiesTable'; +import AllEntitiesTable from 'src/views/CRUD/allentities/AllEntitiesTable'; const AllEntitiesContainer = styled.div` ${({ theme }) => ` diff --git a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.test.jsx b/superset-frontend/src/pages/AnnotationLayerList/AnnotationLayerList.test.jsx similarity index 98% rename from superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.test.jsx rename to superset-frontend/src/pages/AnnotationLayerList/AnnotationLayerList.test.jsx index 541fd0355575c..d3efb1f8befb0 100644 --- a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.test.jsx +++ b/superset-frontend/src/pages/AnnotationLayerList/AnnotationLayerList.test.jsx @@ -23,7 +23,7 @@ import fetchMock from 'fetch-mock'; import { Provider } from 'react-redux'; import { styledMount as mount } from 'spec/helpers/theming'; -import AnnotationLayersList from 'src/views/CRUD/annotationlayers/AnnotationLayersList'; +import AnnotationLayersList from 'src/pages/AnnotationLayerList'; import AnnotationLayerModal from 'src/views/CRUD/annotationlayers/AnnotationLayerModal'; import SubMenu from 'src/views/components/SubMenu'; import ListView from 'src/components/ListView'; diff --git a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx b/superset-frontend/src/pages/AnnotationLayerList/index.tsx similarity index 98% rename from superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx rename to superset-frontend/src/pages/AnnotationLayerList/index.tsx index 3f4e1cd66c0e5..9b30934ce4a7b 100644 --- a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx +++ b/superset-frontend/src/pages/AnnotationLayerList/index.tsx @@ -34,8 +34,8 @@ import ListView, { } from 'src/components/ListView'; import DeleteModal from 'src/components/DeleteModal'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; -import AnnotationLayerModal from './AnnotationLayerModal'; -import { AnnotationLayerObject } from './types'; +import AnnotationLayerModal from 'src/views/CRUD/annotationlayers/AnnotationLayerModal'; +import { AnnotationLayerObject } from 'src/views/CRUD/annotationlayers/types'; const PAGE_SIZE = 25; const MOMENT_FORMAT = 'MMM DD, YYYY'; diff --git a/superset-frontend/src/views/CRUD/annotation/AnnotationList.test.jsx b/superset-frontend/src/pages/AnnotationList/AnnotationList.test.jsx similarity index 98% rename from superset-frontend/src/views/CRUD/annotation/AnnotationList.test.jsx rename to superset-frontend/src/pages/AnnotationList/AnnotationList.test.jsx index e4ddfb3695aa4..26cd063d57fca 100644 --- a/superset-frontend/src/views/CRUD/annotation/AnnotationList.test.jsx +++ b/superset-frontend/src/pages/AnnotationList/AnnotationList.test.jsx @@ -23,7 +23,7 @@ import fetchMock from 'fetch-mock'; import { Provider } from 'react-redux'; import { styledMount as mount } from 'spec/helpers/theming'; -import AnnotationList from 'src/views/CRUD/annotation/AnnotationList'; +import AnnotationList from 'src/pages/AnnotationList'; import DeleteModal from 'src/components/DeleteModal'; import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; import ListView from 'src/components/ListView'; diff --git a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx b/superset-frontend/src/pages/AnnotationList/index.tsx similarity index 98% rename from superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx rename to superset-frontend/src/pages/AnnotationList/index.tsx index f92bedd15d58f..dead2fc72666e 100644 --- a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx +++ b/superset-frontend/src/pages/AnnotationList/index.tsx @@ -33,8 +33,8 @@ import withToasts from 'src/components/MessageToasts/withToasts'; import { useListViewResource } from 'src/views/CRUD/hooks'; import { createErrorHandler } from 'src/views/CRUD/utils'; -import { AnnotationObject } from './types'; -import AnnotationModal from './AnnotationModal'; +import { AnnotationObject } from 'src/views/CRUD/annotation/types'; +import AnnotationModal from 'src/views/CRUD/annotation/AnnotationModal'; const PAGE_SIZE = 25; diff --git a/superset-frontend/src/explore/ExplorePage.tsx b/superset-frontend/src/pages/Chart/index.tsx similarity index 89% rename from superset-frontend/src/explore/ExplorePage.tsx rename to superset-frontend/src/pages/Chart/index.tsx index cea43560afb69..fae85be59fa24 100644 --- a/superset-frontend/src/explore/ExplorePage.tsx +++ b/superset-frontend/src/pages/Chart/index.tsx @@ -34,13 +34,13 @@ import { URL_PARAMS } from 'src/constants'; import { getClientErrorObject } from 'src/utils/getClientErrorObject'; import getFormDataWithExtraFilters from 'src/dashboard/util/charts/getFormDataWithExtraFilters'; import { getAppliedFilterValues } from 'src/dashboard/util/activeDashboardFilters'; -import { getParsedExploreURLParams } from './exploreUtils/getParsedExploreURLParams'; -import { hydrateExplore } from './actions/hydrateExplore'; -import ExploreViewContainer from './components/ExploreViewContainer'; -import { ExploreResponsePayload, SaveActionType } from './types'; -import { fallbackExploreInitialData } from './fixtures'; -import { getItem, LocalStorageKeys } from '../utils/localStorageHelpers'; -import { getFormDataWithDashboardContext } from './controlUtils/getFormDataWithDashboardContext'; +import { getParsedExploreURLParams } from 'src/explore/exploreUtils/getParsedExploreURLParams'; +import { hydrateExplore } from 'src/explore/actions/hydrateExplore'; +import ExploreViewContainer from 'src/explore/components/ExploreViewContainer'; +import { ExploreResponsePayload, SaveActionType } from 'src/explore/types'; +import { fallbackExploreInitialData } from 'src/explore/fixtures'; +import { getItem, LocalStorageKeys } from 'src/utils/localStorageHelpers'; +import { getFormDataWithDashboardContext } from 'src/explore/controlUtils/getFormDataWithDashboardContext'; const isValidResult = (rv: JsonObject): boolean => rv?.result?.form_data && isDefined(rv?.result?.dataset?.id); diff --git a/superset-frontend/src/pages/ChartCreation/index.tsx b/superset-frontend/src/pages/ChartCreation/index.tsx index 2a01a9123af12..baa0c78f1a08d 100644 --- a/superset-frontend/src/pages/ChartCreation/index.tsx +++ b/superset-frontend/src/pages/ChartCreation/index.tsx @@ -33,6 +33,7 @@ import Button from 'src/components/Button'; import { AsyncSelect, Steps } from 'src/components'; import { Tooltip } from 'src/components/Tooltip'; import withToasts from 'src/components/MessageToasts/withToasts'; +import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import VizTypeGallery, { MAX_ADVISABLE_VIZ_GALLERY_WIDTH, @@ -66,6 +67,13 @@ const ELEMENTS_EXCEPT_VIZ_GALLERY = ESTIMATED_NAV_HEIGHT + 250; const bootstrapData = getBootstrapData(); const denyList: string[] = bootstrapData.common.conf.VIZ_TYPE_DENYLIST || []; +if ( + isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) && + !('filter_box' in denyList) +) { + denyList.push('filter_box'); +} + const StyledContainer = styled.div` ${({ theme }) => ` flex: 1 1 auto; diff --git a/superset-frontend/src/pages/ChartList/index.tsx b/superset-frontend/src/pages/ChartList/index.tsx index 9788d61e4a1fd..1da3394fd2350 100644 --- a/superset-frontend/src/pages/ChartList/index.tsx +++ b/superset-frontend/src/pages/ChartList/index.tsx @@ -70,7 +70,7 @@ import { GenericLink } from 'src/components/GenericLink/GenericLink'; import getBootstrapData from 'src/utils/getBootstrapData'; import Owner from 'src/types/Owner'; import { loadTags } from 'src/components/Tags/utils'; -import ChartCard from './ChartCard'; +import ChartCard from 'src/views/CRUD/chart/ChartCard'; const FlexRowContainer = styled.div` align-items: center; diff --git a/superset-frontend/src/views/CRUD/csstemplates/CssTemplatesList.test.jsx b/superset-frontend/src/pages/CssTemplateList/CssTemplateList.test.jsx similarity index 98% rename from superset-frontend/src/views/CRUD/csstemplates/CssTemplatesList.test.jsx rename to superset-frontend/src/pages/CssTemplateList/CssTemplateList.test.jsx index e0b6df9ddf486..d513523a7e565 100644 --- a/superset-frontend/src/views/CRUD/csstemplates/CssTemplatesList.test.jsx +++ b/superset-frontend/src/pages/CssTemplateList/CssTemplateList.test.jsx @@ -23,7 +23,7 @@ import { Provider } from 'react-redux'; import fetchMock from 'fetch-mock'; import { styledMount as mount } from 'spec/helpers/theming'; -import CssTemplatesList from 'src/views/CRUD/csstemplates/CssTemplatesList'; +import CssTemplatesList from 'src/pages/CssTemplateList'; import SubMenu from 'src/views/components/SubMenu'; import ListView from 'src/components/ListView'; import Filters from 'src/components/ListView/Filters'; diff --git a/superset-frontend/src/views/CRUD/csstemplates/CssTemplatesList.tsx b/superset-frontend/src/pages/CssTemplateList/index.tsx similarity index 98% rename from superset-frontend/src/views/CRUD/csstemplates/CssTemplatesList.tsx rename to superset-frontend/src/pages/CssTemplateList/index.tsx index 253a4f4cdf1be..4d678b0a3269f 100644 --- a/superset-frontend/src/views/CRUD/csstemplates/CssTemplatesList.tsx +++ b/superset-frontend/src/pages/CssTemplateList/index.tsx @@ -35,8 +35,8 @@ import ListView, { Filters, FilterOperator, } from 'src/components/ListView'; -import CssTemplateModal from './CssTemplateModal'; -import { TemplateObject } from './types'; +import CssTemplateModal from 'src/views/CRUD/csstemplates/CssTemplateModal'; +import { TemplateObject } from 'src/views/CRUD/csstemplates/types'; const PAGE_SIZE = 25; diff --git a/superset-frontend/src/dashboard/containers/DashboardRoute.tsx b/superset-frontend/src/pages/Dashboard/index.tsx similarity index 93% rename from superset-frontend/src/dashboard/containers/DashboardRoute.tsx rename to superset-frontend/src/pages/Dashboard/index.tsx index a382a28d4586f..6e694c1804821 100644 --- a/superset-frontend/src/dashboard/containers/DashboardRoute.tsx +++ b/superset-frontend/src/pages/Dashboard/index.tsx @@ -18,7 +18,7 @@ */ import React, { FC } from 'react'; import { useParams } from 'react-router-dom'; -import { DashboardPage } from './DashboardPage'; +import { DashboardPage } from 'src/dashboard/containers/DashboardPage'; const DashboardRoute: FC = () => { const { idOrSlug } = useParams<{ idOrSlug: string }>(); diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardList.test.jsx b/superset-frontend/src/pages/DashboardList/DashboardList.test.jsx similarity index 99% rename from superset-frontend/src/views/CRUD/dashboard/DashboardList.test.jsx rename to superset-frontend/src/pages/DashboardList/DashboardList.test.jsx index e42ba92ff2926..be04c323318ec 100644 --- a/superset-frontend/src/views/CRUD/dashboard/DashboardList.test.jsx +++ b/superset-frontend/src/pages/DashboardList/DashboardList.test.jsx @@ -32,7 +32,7 @@ import { QueryParamProvider } from 'use-query-params'; import { act } from 'react-dom/test-utils'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; -import DashboardList from 'src/views/CRUD/dashboard/DashboardList'; +import DashboardList from 'src/pages/DashboardList'; import ListView from 'src/components/ListView'; import ListViewCard from 'src/components/ListViewCard'; import PropertiesModal from 'src/dashboard/components/PropertiesModal'; diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx b/superset-frontend/src/pages/DashboardList/index.tsx similarity index 99% rename from superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx rename to superset-frontend/src/pages/DashboardList/index.tsx index d6d192e22b75d..f8a2ea31aaa09 100644 --- a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx +++ b/superset-frontend/src/pages/DashboardList/index.tsx @@ -55,8 +55,8 @@ import { Dashboard as CRUDDashboard } from 'src/views/CRUD/types'; import CertifiedBadge from 'src/components/CertifiedBadge'; import { loadTags } from 'src/components/Tags/utils'; import getBootstrapData from 'src/utils/getBootstrapData'; -import DashboardCard from './DashboardCard'; -import { DashboardStatus } from './types'; +import DashboardCard from 'src/views/CRUD/dashboard/DashboardCard'; +import { DashboardStatus } from 'src/views/CRUD/dashboard/types'; const PAGE_SIZE = 25; const PASSWORDS_NEEDED_MESSAGE = t( diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx b/superset-frontend/src/pages/DatabaseList/DatabaseList.test.jsx similarity index 99% rename from superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx rename to superset-frontend/src/pages/DatabaseList/DatabaseList.test.jsx index f6fc0481f6521..42cf1491d2617 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx +++ b/superset-frontend/src/pages/DatabaseList/DatabaseList.test.jsx @@ -24,7 +24,7 @@ import fetchMock from 'fetch-mock'; import { Provider } from 'react-redux'; import { styledMount as mount } from 'spec/helpers/theming'; -import DatabaseList from 'src/views/CRUD/data/database/DatabaseList'; +import DatabaseList from 'src/pages/DatabaseList'; import DatabaseModal from 'src/views/CRUD/data/database/DatabaseModal'; import DeleteModal from 'src/components/DeleteModal'; import SubMenu from 'src/views/components/SubMenu'; diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx similarity index 99% rename from superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx rename to superset-frontend/src/pages/DatabaseList/index.tsx index 00f207ba7a84c..c20c3b006b9c4 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx +++ b/superset-frontend/src/pages/DatabaseList/index.tsx @@ -40,9 +40,8 @@ import handleResourceExport from 'src/utils/export'; import { ExtensionConfigs } from 'src/views/components/types'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import type { MenuObjectProps } from 'src/types/bootstrapTypes'; -import DatabaseModal from './DatabaseModal'; - -import { DatabaseObject } from './types'; +import DatabaseModal from 'src/views/CRUD/data/database/DatabaseModal'; +import { DatabaseObject } from 'src/views/CRUD/data/database/types'; const PAGE_SIZE = 25; diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/AddDataset.test.tsx b/superset-frontend/src/pages/DatasetCreation/DatasetCreation.test.tsx similarity index 96% rename from superset-frontend/src/views/CRUD/data/dataset/AddDataset/AddDataset.test.tsx rename to superset-frontend/src/pages/DatasetCreation/DatasetCreation.test.tsx index ea595c0f901a0..41b32965e81b7 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/AddDataset.test.tsx +++ b/superset-frontend/src/pages/DatasetCreation/DatasetCreation.test.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; import { render, screen } from 'spec/helpers/testing-library'; -import AddDataset from 'src/views/CRUD/data/dataset/AddDataset'; +import AddDataset from 'src/pages/DatasetCreation'; const mockHistoryPush = jest.fn(); jest.mock('react-router-dom', () => ({ diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/index.tsx b/superset-frontend/src/pages/DatasetCreation/index.tsx similarity index 86% rename from superset-frontend/src/views/CRUD/data/dataset/AddDataset/index.tsx rename to superset-frontend/src/pages/DatasetCreation/index.tsx index 67b108ab366f1..18c180b2d32d5 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/index.tsx +++ b/superset-frontend/src/pages/DatasetCreation/index.tsx @@ -19,13 +19,17 @@ import React, { useReducer, Reducer, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { useDatasetsList } from 'src/views/CRUD/data/hooks'; -import Header from './Header'; -import EditPage from './EditDataset'; -import DatasetPanel from './DatasetPanel'; -import LeftPanel from './LeftPanel'; -import Footer from './Footer'; -import { DatasetActionType, DatasetObject, DSReducerActionType } from './types'; -import DatasetLayout from '../DatasetLayout'; +import Header from 'src/views/CRUD/data/dataset/AddDataset/Header'; +import EditPage from 'src/views/CRUD/data/dataset/AddDataset/EditDataset'; +import DatasetPanel from 'src/views/CRUD/data/dataset/AddDataset/DatasetPanel'; +import LeftPanel from 'src/views/CRUD/data/dataset/AddDataset/LeftPanel'; +import Footer from 'src/views/CRUD/data/dataset/AddDataset/Footer'; +import { + DatasetActionType, + DatasetObject, + DSReducerActionType, +} from 'src/views/CRUD/data/dataset/AddDataset/types'; +import DatasetLayout from 'src/views/CRUD/data/dataset/DatasetLayout'; type Schema = { schema: string; diff --git a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.test.tsx similarity index 99% rename from superset-frontend/src/views/CRUD/data/dataset/DatasetList.test.tsx rename to superset-frontend/src/pages/DatasetList/DatasetList.test.tsx index cf64ee6ac5bbc..1ce9a7bc0f636 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.test.tsx +++ b/superset-frontend/src/pages/DatasetList/DatasetList.test.tsx @@ -27,7 +27,7 @@ import userEvent from '@testing-library/user-event'; import { QueryParamProvider } from 'use-query-params'; import * as featureFlags from 'src/featureFlags'; -import DatasetList from 'src/views/CRUD/data/dataset/DatasetList'; +import DatasetList from 'src/pages/DatasetList'; import ListView from 'src/components/ListView'; import Button from 'src/components/Button'; import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; diff --git a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx b/superset-frontend/src/pages/DatasetList/index.tsx similarity index 99% rename from superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx rename to superset-frontend/src/pages/DatasetList/index.tsx index bc3342f69c3b8..bdb2683ad45aa 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx +++ b/superset-frontend/src/pages/DatasetList/index.tsx @@ -64,8 +64,8 @@ import { SORT_BY, PASSWORDS_NEEDED_MESSAGE, CONFIRM_OVERWRITE_MESSAGE, -} from './constants'; -import DuplicateDatasetModal from './DuplicateDatasetModal'; +} from 'src/views/CRUD/data/dataset/constants'; +import DuplicateDatasetModal from 'src/views/CRUD/data/dataset/DuplicateDatasetModal'; const FlexRowContainer = styled.div` align-items: center; diff --git a/superset-frontend/src/views/CRUD/alert/ExecutionLog.test.jsx b/superset-frontend/src/pages/ExecutionLogList/ExecutionLogList.test.jsx similarity index 98% rename from superset-frontend/src/views/CRUD/alert/ExecutionLog.test.jsx rename to superset-frontend/src/pages/ExecutionLogList/ExecutionLogList.test.jsx index 5029a2ccbeb36..157ac24d9e3b3 100644 --- a/superset-frontend/src/views/CRUD/alert/ExecutionLog.test.jsx +++ b/superset-frontend/src/pages/ExecutionLogList/ExecutionLogList.test.jsx @@ -24,7 +24,7 @@ import thunk from 'redux-thunk'; import { styledMount as mount } from 'spec/helpers/theming'; import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; import ListView from 'src/components/ListView'; -import ExecutionLog from 'src/views/CRUD/alert/ExecutionLog'; +import ExecutionLog from 'src/pages/ExecutionLogList'; // store needed for withToasts(ExecutionLog) const mockStore = configureStore([thunk]); diff --git a/superset-frontend/src/views/CRUD/alert/ExecutionLog.tsx b/superset-frontend/src/pages/ExecutionLogList/index.tsx similarity index 98% rename from superset-frontend/src/views/CRUD/alert/ExecutionLog.tsx rename to superset-frontend/src/pages/ExecutionLogList/index.tsx index 61b801c1c091a..48c1bde788ff5 100644 --- a/superset-frontend/src/views/CRUD/alert/ExecutionLog.tsx +++ b/superset-frontend/src/pages/ExecutionLogList/index.tsx @@ -31,7 +31,7 @@ import { useListViewResource, useSingleViewResource, } from 'src/views/CRUD/hooks'; -import { AlertObject, LogObject } from './types'; +import { AlertObject, LogObject } from 'src/views/CRUD/alert/types'; const PAGE_SIZE = 25; diff --git a/superset-frontend/src/views/CRUD/welcome/Welcome.test.tsx b/superset-frontend/src/pages/Home/Home.test.tsx similarity index 99% rename from superset-frontend/src/views/CRUD/welcome/Welcome.test.tsx rename to superset-frontend/src/pages/Home/Home.test.tsx index dffe5acdd0a89..fd90d1624a530 100644 --- a/superset-frontend/src/views/CRUD/welcome/Welcome.test.tsx +++ b/superset-frontend/src/pages/Home/Home.test.tsx @@ -24,7 +24,7 @@ import fetchMock from 'fetch-mock'; import { act } from 'react-dom/test-utils'; import configureStore from 'redux-mock-store'; import * as featureFlags from 'src/featureFlags'; -import Welcome from 'src/views/CRUD/welcome/Welcome'; +import Welcome from 'src/pages/Home'; import { ReactWrapper } from 'enzyme'; import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; import { render, screen } from 'spec/helpers/testing-library'; diff --git a/superset-frontend/src/views/CRUD/welcome/Welcome.tsx b/superset-frontend/src/pages/Home/index.tsx similarity index 97% rename from superset-frontend/src/views/CRUD/welcome/Welcome.tsx rename to superset-frontend/src/pages/Home/index.tsx index 9da45615fbfc4..fa1d3649c48e4 100644 --- a/superset-frontend/src/views/CRUD/welcome/Welcome.tsx +++ b/superset-frontend/src/pages/Home/index.tsx @@ -49,11 +49,11 @@ import { AntdSwitch } from 'src/components'; import getBootstrapData from 'src/utils/getBootstrapData'; import { TableTab } from 'src/views/CRUD/types'; import { canUserAccessSqlLab } from 'src/dashboard/util/permissionUtils'; -import { WelcomePageLastTab } from './types'; -import ActivityTable from './ActivityTable'; -import ChartTable from './ChartTable'; -import SavedQueries from './SavedQueries'; -import DashboardTable from './DashboardTable'; +import { WelcomePageLastTab } from 'src/views/CRUD/welcome/types'; +import ActivityTable from 'src/views/CRUD/welcome/ActivityTable'; +import ChartTable from 'src/views/CRUD/welcome/ChartTable'; +import SavedQueries from 'src/views/CRUD/welcome/SavedQueries'; +import DashboardTable from 'src/views/CRUD/welcome/DashboardTable'; const extensionsRegistry = getExtensionsRegistry(); diff --git a/superset-frontend/src/views/CRUD/data/query/QueryList.test.tsx b/superset-frontend/src/pages/QueryHistoryList/QueryHistoryList.test.tsx similarity index 98% rename from superset-frontend/src/views/CRUD/data/query/QueryList.test.tsx rename to superset-frontend/src/pages/QueryHistoryList/QueryHistoryList.test.tsx index be28d7e2dfa85..540dd47ad8e78 100644 --- a/superset-frontend/src/views/CRUD/data/query/QueryList.test.tsx +++ b/superset-frontend/src/pages/QueryHistoryList/QueryHistoryList.test.tsx @@ -26,7 +26,7 @@ import { act } from 'react-dom/test-utils'; import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; import { styledMount as mount } from 'spec/helpers/theming'; -import QueryList from 'src/views/CRUD/data/query/QueryList'; +import QueryList from 'src/pages/QueryHistoryList'; import QueryPreviewModal from 'src/views/CRUD/data/query/QueryPreviewModal'; import { QueryObject } from 'src/views/CRUD/types'; import ListView from 'src/components/ListView'; diff --git a/superset-frontend/src/views/CRUD/data/query/QueryList.tsx b/superset-frontend/src/pages/QueryHistoryList/index.tsx similarity index 99% rename from superset-frontend/src/views/CRUD/data/query/QueryList.tsx rename to superset-frontend/src/pages/QueryHistoryList/index.tsx index ff7a268f20b97..ffed99d3ffb53 100644 --- a/superset-frontend/src/views/CRUD/data/query/QueryList.tsx +++ b/superset-frontend/src/pages/QueryHistoryList/index.tsx @@ -49,7 +49,7 @@ import { DATETIME_WITH_TIME_ZONE, TIME_WITH_MS } from 'src/constants'; import { QueryObject, QueryObjectColumns } from 'src/views/CRUD/types'; import Icons from 'src/components/Icons'; -import QueryPreviewModal from './QueryPreviewModal'; +import QueryPreviewModal from 'src/views/CRUD/data/query/QueryPreviewModal'; const PAGE_SIZE = 25; const SQL_PREVIEW_MAX_LINES = 4; diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.test.jsx b/superset-frontend/src/pages/SavedQueryList/SavedQueryList.test.jsx similarity index 99% rename from superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.test.jsx rename to superset-frontend/src/pages/SavedQueryList/SavedQueryList.test.jsx index afa0fcd6aeacf..8882d81491070 100644 --- a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.test.jsx +++ b/superset-frontend/src/pages/SavedQueryList/SavedQueryList.test.jsx @@ -28,7 +28,7 @@ import userEvent from '@testing-library/user-event'; import { QueryParamProvider } from 'use-query-params'; import { act } from 'react-dom/test-utils'; import * as featureFlags from 'src/featureFlags'; -import SavedQueryList from 'src/views/CRUD/data/savedquery/SavedQueryList'; +import SavedQueryList from 'src/pages/SavedQueryList'; import SubMenu from 'src/views/components/SubMenu'; import ListView from 'src/components/ListView'; import Filters from 'src/components/ListView/Filters'; diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx b/superset-frontend/src/pages/SavedQueryList/index.tsx similarity index 99% rename from superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx rename to superset-frontend/src/pages/SavedQueryList/index.tsx index d3c96d4c30656..5f57de5713987 100644 --- a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx +++ b/superset-frontend/src/pages/SavedQueryList/index.tsx @@ -53,7 +53,7 @@ import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import ImportModelsModal from 'src/components/ImportModal/index'; import Icons from 'src/components/Icons'; import { BootstrapUser } from 'src/types/bootstrapTypes'; -import SavedQueryPreviewModal from './SavedQueryPreviewModal'; +import SavedQueryPreviewModal from 'src/views/CRUD/data/savedquery/SavedQueryPreviewModal'; const PAGE_SIZE = 25; const PASSWORDS_NEEDED_MESSAGE = t( diff --git a/superset-frontend/src/views/CRUD/tags/TagList.tsx b/superset-frontend/src/pages/Tags/index.tsx similarity index 99% rename from superset-frontend/src/views/CRUD/tags/TagList.tsx rename to superset-frontend/src/pages/Tags/index.tsx index 92028168f4558..440e33bf9c690 100644 --- a/superset-frontend/src/views/CRUD/tags/TagList.tsx +++ b/superset-frontend/src/pages/Tags/index.tsx @@ -40,8 +40,8 @@ import FacePile from 'src/components/FacePile'; import { Link } from 'react-router-dom'; import { deleteTags } from 'src/tags'; import { Tag as AntdTag } from 'antd'; -import { Tag } from '../types'; -import TagCard from './TagCard'; +import { Tag } from 'src/views/CRUD/types'; +import TagCard from 'src/views/CRUD/tags/TagCard'; const PAGE_SIZE = 25; diff --git a/superset-frontend/src/pages/ChartList/ChartCard.tsx b/superset-frontend/src/views/CRUD/chart/ChartCard.tsx similarity index 100% rename from superset-frontend/src/pages/ChartList/ChartCard.tsx rename to superset-frontend/src/views/CRUD/chart/ChartCard.tsx diff --git a/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx b/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx index 11a3e9afdb83c..08c18f76556ab 100644 --- a/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx +++ b/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx @@ -24,7 +24,7 @@ import { Link } from 'react-router-dom'; import ListViewCard from 'src/components/ListViewCard'; import SubMenu from 'src/views/components/SubMenu'; import { Dashboard, SavedQueryObject, TableTab } from 'src/views/CRUD/types'; -import { ActivityData, LoadingCards } from 'src/views/CRUD/welcome/Welcome'; +import { ActivityData, LoadingCards } from 'src/pages/Home'; import { CardContainer, CardStyles, diff --git a/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx b/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx index 52548e30b0c03..d0d4db4105902 100644 --- a/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx +++ b/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx @@ -39,8 +39,8 @@ import { getFilterValues, PAGE_SIZE, } from 'src/views/CRUD/utils'; -import { LoadingCards } from 'src/views/CRUD/welcome/Welcome'; -import ChartCard from 'src/pages/ChartList/ChartCard'; +import { LoadingCards } from 'src/pages/Home'; +import ChartCard from 'src/views/CRUD/chart/ChartCard'; import Chart from 'src/types/Chart'; import handleResourceExport from 'src/utils/export'; import Loading from 'src/components/Loading'; diff --git a/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx b/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx index e4a31bdf4f6de..b38b0b84778ae 100644 --- a/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx +++ b/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx @@ -28,7 +28,7 @@ import { LocalStorageKeys, setItem, } from 'src/utils/localStorageHelpers'; -import { LoadingCards } from 'src/views/CRUD/welcome/Welcome'; +import { LoadingCards } from 'src/pages/Home'; import { CardContainer, createErrorHandler, diff --git a/superset-frontend/src/views/CRUD/welcome/SavedQueries.tsx b/superset-frontend/src/views/CRUD/welcome/SavedQueries.tsx index 4e323e633f225..c7d43b0e4b339 100644 --- a/superset-frontend/src/views/CRUD/welcome/SavedQueries.tsx +++ b/superset-frontend/src/views/CRUD/welcome/SavedQueries.tsx @@ -21,7 +21,7 @@ import { styled, SupersetClient, t, useTheme } from '@superset-ui/core'; import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light'; import sql from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql'; import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github'; -import { LoadingCards } from 'src/views/CRUD/welcome/Welcome'; +import { LoadingCards } from 'src/pages/Home'; import { TableTab } from 'src/views/CRUD/types'; import withToasts from 'src/components/MessageToasts/withToasts'; import { AntdDropdown } from 'src/components'; diff --git a/superset-frontend/src/views/routes.test.tsx b/superset-frontend/src/views/routes.test.tsx index 2497dce15a907..3b01288bfdfd0 100644 --- a/superset-frontend/src/views/routes.test.tsx +++ b/superset-frontend/src/views/routes.test.tsx @@ -23,9 +23,7 @@ jest.mock('src/featureFlags', () => ({ ...jest.requireActual('src/featureFlags'), isFeatureEnabled: jest.fn().mockReturnValue(true), })); -jest.mock('src/views/CRUD/welcome/Welcome', () => () => ( -
-)); +jest.mock('src/pages/Home', () => () =>
); describe('isFrontendRoute', () => { it('returns true if a route matches', () => { diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index 633966ca74184..1aaefaa538f17 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -20,100 +20,96 @@ import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import React, { lazy } from 'react'; // not lazy loaded since this is the home page. -import Welcome from 'src/views/CRUD/welcome/Welcome'; +import Home from 'src/pages/Home'; const ChartCreation = lazy( () => import(/* webpackChunkName: "ChartCreation" */ 'src/pages/ChartCreation'), ); -const AnnotationLayersList = lazy( + +const AnnotationLayerList = lazy( () => import( - /* webpackChunkName: "AnnotationLayersList" */ 'src/views/CRUD/annotationlayers/AnnotationLayersList' + /* webpackChunkName: "AnnotationLayerList" */ 'src/pages/AnnotationLayerList' ), ); -const AlertList = lazy( + +const AlertReportList = lazy( () => import( - /* webpackChunkName: "AlertList" */ 'src/views/CRUD/alert/AlertList' + /* webpackChunkName: "AlertReportList" */ 'src/pages/AlertReportList' ), ); + const AnnotationList = lazy( () => - import( - /* webpackChunkName: "AnnotationList" */ 'src/views/CRUD/annotation/AnnotationList' - ), + import(/* webpackChunkName: "AnnotationList" */ 'src/pages/AnnotationList'), ); + const ChartList = lazy( () => import(/* webpackChunkName: "ChartList" */ 'src/pages/ChartList'), ); -const CssTemplatesList = lazy( + +const CssTemplateList = lazy( () => import( - /* webpackChunkName: "CssTemplatesList" */ 'src/views/CRUD/csstemplates/CssTemplatesList' + /* webpackChunkName: "CssTemplateList" */ 'src/pages/CssTemplateList' ), ); + const DashboardList = lazy( () => - import( - /* webpackChunkName: "DashboardList" */ 'src/views/CRUD/dashboard/DashboardList' - ), + import(/* webpackChunkName: "DashboardList" */ 'src/pages/DashboardList'), ); -const DashboardRoute = lazy( - () => - import( - /* webpackChunkName: "DashboardRoute" */ 'src/dashboard/containers/DashboardRoute' - ), + +const Dashboard = lazy( + () => import(/* webpackChunkName: "Dashboard" */ 'src/pages/Dashboard'), ); + const DatabaseList = lazy( - () => - import( - /* webpackChunkName: "DatabaseList" */ 'src/views/CRUD/data/database/DatabaseList' - ), + () => import(/* webpackChunkName: "DatabaseList" */ 'src/pages/DatabaseList'), ); + const DatasetList = lazy( - () => - import( - /* webpackChunkName: "DatasetList" */ 'src/views/CRUD/data/dataset/DatasetList' - ), + () => import(/* webpackChunkName: "DatasetList" */ 'src/pages/DatasetList'), ); -const AddDataset = lazy( +const DatasetCreation = lazy( () => import( - /* webpackChunkName: "DatasetEditor" */ 'src/views/CRUD/data/dataset/AddDataset/index' + /* webpackChunkName: "DatasetCreation" */ 'src/pages/DatasetCreation' ), ); -const ExecutionLog = lazy( +const ExecutionLogList = lazy( () => import( - /* webpackChunkName: "ExecutionLog" */ 'src/views/CRUD/alert/ExecutionLog' + /* webpackChunkName: "ExecutionLogList" */ 'src/pages/ExecutionLogList' ), ); -const ExplorePage = lazy( - () => import(/* webpackChunkName: "ExplorePage" */ 'src/explore/ExplorePage'), + +const Chart = lazy( + () => import(/* webpackChunkName: "Chart" */ 'src/pages/Chart'), ); -const QueryList = lazy( + +const QueryHistoryList = lazy( () => import( - /* webpackChunkName: "QueryList" */ 'src/views/CRUD/data/query/QueryList' + /* webpackChunkName: "QueryHistoryList" */ 'src/pages/QueryHistoryList' ), ); + const SavedQueryList = lazy( () => - import( - /* webpackChunkName: "SavedQueryList" */ 'src/views/CRUD/data/savedquery/SavedQueryList' - ), + import(/* webpackChunkName: "SavedQueryList" */ 'src/pages/SavedQueryList'), ); -const AllEntitiesPage = lazy( - () => - import( - /* webpackChunkName: "AllEntities" */ 'src/views/CRUD/allentities/AllEntities' - ), + +const AllEntities = lazy( + () => import(/* webpackChunkName: "AllEntities" */ 'src/pages/AllEntities'), ); -const TagsPage = lazy( - () => import(/* webpackChunkName: "TagList" */ 'src/views/CRUD/tags/TagList'), + +const Tags = lazy( + () => import(/* webpackChunkName: "Tags" */ 'src/pages/Tags'), ); type Routes = { @@ -126,7 +122,7 @@ type Routes = { export const routes: Routes = [ { path: '/superset/welcome/', - Component: Welcome, + Component: Home, }, { path: '/dashboard/list/', @@ -134,7 +130,7 @@ export const routes: Routes = [ }, { path: '/superset/dashboard/:idOrSlug/', - Component: DashboardRoute, + Component: Dashboard, }, { path: '/chart/add', @@ -158,11 +154,11 @@ export const routes: Routes = [ }, { path: '/csstemplatemodelview/list/', - Component: CssTemplatesList, + Component: CssTemplateList, }, { path: '/annotationlayer/list/', - Component: AnnotationLayersList, + Component: AnnotationLayerList, }, { path: '/annotationlayer/:annotationLayerId/annotation/', @@ -170,56 +166,56 @@ export const routes: Routes = [ }, { path: '/superset/sqllab/history/', - Component: QueryList, + Component: QueryHistoryList, }, { path: '/alert/list/', - Component: AlertList, + Component: AlertReportList, }, { path: '/report/list/', - Component: AlertList, + Component: AlertReportList, props: { isReportEnabled: true, }, }, { path: '/alert/:alertId/log/', - Component: ExecutionLog, + Component: ExecutionLogList, }, { path: '/report/:alertId/log/', - Component: ExecutionLog, + Component: ExecutionLogList, props: { isReportEnabled: true, }, }, { path: '/explore/', - Component: ExplorePage, + Component: Chart, }, { path: '/superset/explore/p', - Component: ExplorePage, + Component: Chart, }, { path: '/dataset/add/', - Component: AddDataset, + Component: DatasetCreation, }, { path: '/dataset/:datasetId', - Component: AddDataset, + Component: DatasetCreation, }, ]; if (isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM)) { routes.push({ path: '/superset/all_entities/', - Component: AllEntitiesPage, + Component: AllEntities, }); routes.push({ path: '/superset/tags/', - Component: TagsPage, + Component: Tags, }); } diff --git a/superset-websocket/package-lock.json b/superset-websocket/package-lock.json index 9db45adf7a059..e49fbb1eedabd 100644 --- a/superset-websocket/package-lock.json +++ b/superset-websocket/package-lock.json @@ -22,13 +22,13 @@ "@types/ioredis": "^4.27.8", "@types/jest": "^27.0.2", "@types/jsonwebtoken": "^9.0.1", - "@types/node": "^18.14.1", + "@types/node": "^18.14.6", "@types/uuid": "^9.0.1", "@types/ws": "^8.5.4", "@typescript-eslint/eslint-plugin": "^5.53.0", - "@typescript-eslint/parser": "^5.53.0", + "@typescript-eslint/parser": "^5.54.1", "eslint": "^8.35.0", - "eslint-config-prettier": "^8.6.0", + "eslint-config-prettier": "^8.7.0", "jest": "^27.3.1", "prettier": "^2.8.4", "ts-jest": "^27.0.7", @@ -1294,9 +1294,9 @@ } }, "node_modules/@types/node": { - "version": "18.14.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.1.tgz", - "integrity": "sha512-QH+37Qds3E0eDlReeboBxfHbX9omAcBCXEzswCu6jySP642jiM3cYSIkU/REqwhCUqXdonHFuBfJDiAJxMNhaQ==", + "version": "18.14.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.6.tgz", + "integrity": "sha512-93+VvleD3mXwlLI/xASjw0FzKcwzl3OdTCzm1LaRfqgS21gfFtK3zDXM5Op9TeeMsJVOaJ2VRDpT9q4Y3d0AvA==", "dev": true }, "node_modules/@types/prettier": { @@ -1382,14 +1382,14 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "5.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.53.0.tgz", - "integrity": "sha512-MKBw9i0DLYlmdOb3Oq/526+al20AJZpANdT6Ct9ffxcV8nKCHz63t/S0IhlTFNsBIHJv+GY5SFJ0XfqVeydQrQ==", + "version": "5.54.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.54.1.tgz", + "integrity": "sha512-8zaIXJp/nG9Ff9vQNh7TI+C3nA6q6iIsGJ4B4L6MhZ7mHnTMR4YP5vp2xydmFXIy8rpyIVbNAG44871LMt6ujg==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "5.53.0", - "@typescript-eslint/types": "5.53.0", - "@typescript-eslint/typescript-estree": "5.53.0", + "@typescript-eslint/scope-manager": "5.54.1", + "@typescript-eslint/types": "5.54.1", + "@typescript-eslint/typescript-estree": "5.54.1", "debug": "^4.3.4" }, "engines": { @@ -1408,6 +1408,89 @@ } } }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "5.54.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.54.1.tgz", + "integrity": "sha512-zWKuGliXxvuxyM71UA/EcPxaviw39dB2504LqAmFDjmkpO8qNLHcmzlh6pbHs1h/7YQ9bnsO8CCcYCSA8sykUg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.54.1", + "@typescript-eslint/visitor-keys": "5.54.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "5.54.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.54.1.tgz", + "integrity": "sha512-G9+1vVazrfAfbtmCapJX8jRo2E4MDXxgm/IMOF4oGh3kq7XuK3JRkOg6y2Qu1VsTRmWETyTkWt1wxy7X7/yLkw==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "5.54.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.54.1.tgz", + "integrity": "sha512-bjK5t+S6ffHnVwA0qRPTZrxKSaFYocwFIkZx5k7pvWfsB1I57pO/0M0Skatzzw1sCkjJ83AfGTL0oFIFiDX3bg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.54.1", + "@typescript-eslint/visitor-keys": "5.54.1", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "5.54.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.54.1.tgz", + "integrity": "sha512-q8iSoHTgwCfgcRJ2l2x+xCbu8nBlRAlsQ33k24Adj8eoVBE0f8dUeI+bAa8F84Mv05UGbAx57g2zrRsYIooqQg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.54.1", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", + "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@typescript-eslint/scope-manager": { "version": "5.53.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.53.0.tgz", @@ -2452,9 +2535,9 @@ } }, "node_modules/eslint-config-prettier": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.6.0.tgz", - "integrity": "sha512-bAF0eLpLVqP5oEVUFKpMA+NnRFICwn9X8B5jrR9FcqnYBuPbqWEjTEspPWMj5ye6czoSLDweCzSo3Ko7gGrZaA==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.7.0.tgz", + "integrity": "sha512-HHVXLSlVUhMSmyW4ZzEuvjpwqamgmlfkutD53cYXLikh4pt/modINRcCIApJ84czDxM4GZInwUrromsDdTImTA==", "dev": true, "bin": { "eslint-config-prettier": "bin/cli.js" @@ -6941,9 +7024,9 @@ } }, "@types/node": { - "version": "18.14.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.1.tgz", - "integrity": "sha512-QH+37Qds3E0eDlReeboBxfHbX9omAcBCXEzswCu6jySP642jiM3cYSIkU/REqwhCUqXdonHFuBfJDiAJxMNhaQ==", + "version": "18.14.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.6.tgz", + "integrity": "sha512-93+VvleD3mXwlLI/xASjw0FzKcwzl3OdTCzm1LaRfqgS21gfFtK3zDXM5Op9TeeMsJVOaJ2VRDpT9q4Y3d0AvA==", "dev": true }, "@types/prettier": { @@ -7013,15 +7096,64 @@ } }, "@typescript-eslint/parser": { - "version": "5.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.53.0.tgz", - "integrity": "sha512-MKBw9i0DLYlmdOb3Oq/526+al20AJZpANdT6Ct9ffxcV8nKCHz63t/S0IhlTFNsBIHJv+GY5SFJ0XfqVeydQrQ==", + "version": "5.54.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.54.1.tgz", + "integrity": "sha512-8zaIXJp/nG9Ff9vQNh7TI+C3nA6q6iIsGJ4B4L6MhZ7mHnTMR4YP5vp2xydmFXIy8rpyIVbNAG44871LMt6ujg==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "5.53.0", - "@typescript-eslint/types": "5.53.0", - "@typescript-eslint/typescript-estree": "5.53.0", + "@typescript-eslint/scope-manager": "5.54.1", + "@typescript-eslint/types": "5.54.1", + "@typescript-eslint/typescript-estree": "5.54.1", "debug": "^4.3.4" + }, + "dependencies": { + "@typescript-eslint/scope-manager": { + "version": "5.54.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.54.1.tgz", + "integrity": "sha512-zWKuGliXxvuxyM71UA/EcPxaviw39dB2504LqAmFDjmkpO8qNLHcmzlh6pbHs1h/7YQ9bnsO8CCcYCSA8sykUg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.54.1", + "@typescript-eslint/visitor-keys": "5.54.1" + } + }, + "@typescript-eslint/types": { + "version": "5.54.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.54.1.tgz", + "integrity": "sha512-G9+1vVazrfAfbtmCapJX8jRo2E4MDXxgm/IMOF4oGh3kq7XuK3JRkOg6y2Qu1VsTRmWETyTkWt1wxy7X7/yLkw==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "5.54.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.54.1.tgz", + "integrity": "sha512-bjK5t+S6ffHnVwA0qRPTZrxKSaFYocwFIkZx5k7pvWfsB1I57pO/0M0Skatzzw1sCkjJ83AfGTL0oFIFiDX3bg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.54.1", + "@typescript-eslint/visitor-keys": "5.54.1", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "5.54.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.54.1.tgz", + "integrity": "sha512-q8iSoHTgwCfgcRJ2l2x+xCbu8nBlRAlsQ33k24Adj8eoVBE0f8dUeI+bAa8F84Mv05UGbAx57g2zrRsYIooqQg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.54.1", + "eslint-visitor-keys": "^3.3.0" + } + }, + "eslint-visitor-keys": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", + "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "dev": true + } } }, "@typescript-eslint/scope-manager": { @@ -7963,9 +8095,9 @@ } }, "eslint-config-prettier": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.6.0.tgz", - "integrity": "sha512-bAF0eLpLVqP5oEVUFKpMA+NnRFICwn9X8B5jrR9FcqnYBuPbqWEjTEspPWMj5ye6czoSLDweCzSo3Ko7gGrZaA==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.7.0.tgz", + "integrity": "sha512-HHVXLSlVUhMSmyW4ZzEuvjpwqamgmlfkutD53cYXLikh4pt/modINRcCIApJ84czDxM4GZInwUrromsDdTImTA==", "dev": true, "requires": {} }, diff --git a/superset-websocket/package.json b/superset-websocket/package.json index eaf67c75ae88d..3ceb44e43d5a8 100644 --- a/superset-websocket/package.json +++ b/superset-websocket/package.json @@ -28,13 +28,13 @@ "@types/ioredis": "^4.27.8", "@types/jest": "^27.0.2", "@types/jsonwebtoken": "^9.0.1", - "@types/node": "^18.14.1", + "@types/node": "^18.14.6", "@types/uuid": "^9.0.1", "@types/ws": "^8.5.4", "@typescript-eslint/eslint-plugin": "^5.53.0", - "@typescript-eslint/parser": "^5.53.0", + "@typescript-eslint/parser": "^5.54.1", "eslint": "^8.35.0", - "eslint-config-prettier": "^8.6.0", + "eslint-config-prettier": "^8.7.0", "jest": "^27.3.1", "prettier": "^2.8.4", "ts-jest": "^27.0.7", diff --git a/superset/common/query_actions.py b/superset/common/query_actions.py index bfb3d368789d9..38526475b9349 100644 --- a/superset/common/query_actions.py +++ b/superset/common/query_actions.py @@ -17,7 +17,7 @@ from __future__ import annotations import copy -from typing import Any, Callable, cast, Dict, List, Optional, TYPE_CHECKING +from typing import Any, Callable, Dict, Optional, TYPE_CHECKING from flask_babel import _ @@ -32,7 +32,6 @@ ExtraFiltersReasonType, get_column_name, get_time_filter_status, - is_adhoc_column, ) if TYPE_CHECKING: @@ -102,7 +101,6 @@ def _get_full( datasource = _get_datasource(query_context, query_obj) result_type = query_obj.result_type or query_context.result_type payload = query_context.get_df_payload(query_obj, force_cached=force_cached) - applied_template_filters = payload.get("applied_template_filters", []) df = payload["df"] status = payload["status"] if status != QueryStatus.FAILED: @@ -113,23 +111,23 @@ def _get_full( payload["result_format"] = query_context.result_format del payload["df"] - filters = query_obj.filter - filter_columns = cast(List[str], [flt.get("col") for flt in filters]) - columns = set(datasource.column_names) applied_time_columns, rejected_time_columns = get_time_filter_status( datasource, query_obj.applied_time_extras ) + + applied_filter_columns = payload.get("applied_filter_columns", []) + rejected_filter_columns = payload.get("rejected_filter_columns", []) + del payload["applied_filter_columns"] + del payload["rejected_filter_columns"] payload["applied_filters"] = [ - {"column": get_column_name(col)} - for col in filter_columns - if is_adhoc_column(col) or col in columns or col in applied_template_filters + {"column": get_column_name(col)} for col in applied_filter_columns ] + applied_time_columns payload["rejected_filters"] = [ - {"reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE, "column": col} - for col in filter_columns - if not is_adhoc_column(col) - and col not in columns - and col not in applied_template_filters + { + "reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE, + "column": get_column_name(col), + } + for col in rejected_filter_columns ] + rejected_time_columns if result_type == ChartDataResultType.RESULTS and status != QueryStatus.FAILED: diff --git a/superset/common/query_context_processor.py b/superset/common/query_context_processor.py index 77ca69fcf6f02..703e1d71ddeaa 100644 --- a/superset/common/query_context_processor.py +++ b/superset/common/query_context_processor.py @@ -165,6 +165,8 @@ def get_df_payload( "cache_timeout": self.get_cache_timeout(), "df": cache.df, "applied_template_filters": cache.applied_template_filters, + "applied_filter_columns": cache.applied_filter_columns, + "rejected_filter_columns": cache.rejected_filter_columns, "annotation_data": cache.annotation_data, "error": cache.error_message, "is_cached": cache.is_cached, diff --git a/superset/common/utils/query_cache_manager.py b/superset/common/utils/query_cache_manager.py index 76aa5ddef32e3..7143fcc201a57 100644 --- a/superset/common/utils/query_cache_manager.py +++ b/superset/common/utils/query_cache_manager.py @@ -29,6 +29,7 @@ from superset.extensions import cache_manager from superset.models.helpers import QueryResult from superset.stats_logger import BaseStatsLogger +from superset.superset_typing import Column from superset.utils.cache import set_and_log_cache from superset.utils.core import error_msg_from_exception, get_stacktrace @@ -54,6 +55,8 @@ def __init__( query: str = "", annotation_data: Optional[Dict[str, Any]] = None, applied_template_filters: Optional[List[str]] = None, + applied_filter_columns: Optional[List[Column]] = None, + rejected_filter_columns: Optional[List[Column]] = None, status: Optional[str] = None, error_message: Optional[str] = None, is_loaded: bool = False, @@ -66,6 +69,8 @@ def __init__( self.query = query self.annotation_data = {} if annotation_data is None else annotation_data self.applied_template_filters = applied_template_filters or [] + self.applied_filter_columns = applied_filter_columns or [] + self.rejected_filter_columns = rejected_filter_columns or [] self.status = status self.error_message = error_message @@ -93,6 +98,8 @@ def set_query_result( self.status = query_result.status self.query = query_result.query self.applied_template_filters = query_result.applied_template_filters + self.applied_filter_columns = query_result.applied_filter_columns + self.rejected_filter_columns = query_result.rejected_filter_columns self.error_message = query_result.error_message self.df = query_result.df self.annotation_data = {} if annotation_data is None else annotation_data @@ -107,6 +114,8 @@ def set_query_result( "df": self.df, "query": self.query, "applied_template_filters": self.applied_template_filters, + "applied_filter_columns": self.applied_filter_columns, + "rejected_filter_columns": self.rejected_filter_columns, "annotation_data": self.annotation_data, } if self.is_loaded and key and self.status != QueryStatus.FAILED: @@ -141,7 +150,7 @@ def get( cache_value = _cache[region].get(key) if cache_value: - logger.info("Cache key: %s", key) + logger.debug("Cache key: %s", key) stats_logger.incr("loading_from_cache") try: query_cache.df = cache_value["df"] @@ -150,6 +159,12 @@ def get( query_cache.applied_template_filters = cache_value.get( "applied_template_filters", [] ) + query_cache.applied_filter_columns = cache_value.get( + "applied_filter_columns", [] + ) + query_cache.rejected_filter_columns = cache_value.get( + "rejected_filter_columns", [] + ) query_cache.status = QueryStatus.SUCCESS query_cache.is_loaded = True query_cache.is_cached = cache_value is not None @@ -165,7 +180,7 @@ def get( error_msg_from_exception(ex), exc_info=True, ) - logger.info("Serving from cache") + logger.debug("Serving from cache") if force_cached and not query_cache.is_loaded: logger.warning( diff --git a/superset/config.py b/superset/config.py index 5489d5f67dbfe..ae6c1002634e2 100644 --- a/superset/config.py +++ b/superset/config.py @@ -188,10 +188,11 @@ def _try_json_readsha(filepath: str, length: int) -> Optional[str]: SQLALCHEMY_TRACK_MODIFICATIONS = False # --------------------------------------------------------- -# Your App secret key. Make sure you override it on superset_config.py. +# Your App secret key. Make sure you override it on superset_config.py +# or use `SUPERSET_SECRET_KEY` environment variable. # Use a strong complex alphanumeric string and use a tool to help you generate # a sufficiently random sequence, ex: openssl rand -base64 42" -SECRET_KEY = CHANGE_ME_SECRET_KEY +SECRET_KEY = os.environ.get("SUPERSET_SECRET_KEY") or CHANGE_ME_SECRET_KEY # The SQLAlchemy connection string. SQLALCHEMY_DATABASE_URI = "sqlite:///" + os.path.join(DATA_DIR, "superset.db") @@ -1245,12 +1246,12 @@ def EMAIL_HEADER_MUTATOR( # pylint: disable=invalid-name,unused-argument # creator if either is contained within the list of owners, otherwise the first owner # will be used) and finally `THUMBNAIL_SELENIUM_USER`, set as follows: # ALERT_REPORTS_EXECUTE_AS = [ -# ScheduledTaskExecutor.CREATOR_OWNER, -# ScheduledTaskExecutor.CREATOR, -# ScheduledTaskExecutor.MODIFIER_OWNER, -# ScheduledTaskExecutor.MODIFIER, -# ScheduledTaskExecutor.OWNER, -# ScheduledTaskExecutor.SELENIUM, +# ExecutorType.CREATOR_OWNER, +# ExecutorType.CREATOR, +# ExecutorType.MODIFIER_OWNER, +# ExecutorType.MODIFIER, +# ExecutorType.OWNER, +# ExecutorType.SELENIUM, # ] ALERT_REPORTS_EXECUTE_AS: List[ExecutorType] = [ExecutorType.SELENIUM] # if ALERT_REPORTS_WORKING_TIME_OUT_KILL is True, set a celery hard timeout diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 02bed344c9061..aeaee612cdfeb 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -99,9 +99,11 @@ from superset.db_engine_specs.base import BaseEngineSpec, TimestampExpression from superset.exceptions import ( AdvancedDataTypeResponseError, + ColumnNotFoundException, DatasetInvalidPermissionEvaluationException, QueryClauseValidationException, QueryObjectValidationError, + SupersetGenericDBErrorException, SupersetSecurityException, ) from superset.extensions import feature_flag_manager @@ -150,6 +152,8 @@ class SqlaQuery(NamedTuple): applied_template_filters: List[str] + applied_filter_columns: List[ColumnTyping] + rejected_filter_columns: List[ColumnTyping] cte: Optional[str] extra_cache_keys: List[Any] labels_expected: List[str] @@ -159,6 +163,8 @@ class SqlaQuery(NamedTuple): class QueryStringExtended(NamedTuple): applied_template_filters: Optional[List[str]] + applied_filter_columns: List[ColumnTyping] + rejected_filter_columns: List[ColumnTyping] labels_expected: List[str] prequeries: List[str] sql: str @@ -878,6 +884,8 @@ def get_query_str_extended(self, query_obj: QueryObjectDict) -> QueryStringExten sql = self.mutate_query_from_config(sql) return QueryStringExtended( applied_template_filters=sqlaq.applied_template_filters, + applied_filter_columns=sqlaq.applied_filter_columns, + rejected_filter_columns=sqlaq.rejected_filter_columns, labels_expected=sqlaq.labels_expected, prequeries=sqlaq.prequeries, sql=sql, @@ -1020,13 +1028,16 @@ def adhoc_column_to_sqla( ) is_dttm = col_in_metadata.is_temporal else: - sqla_column = literal_column(expression) - # probe adhoc column type - tbl, _ = self.get_from_clause(template_processor) - qry = sa.select([sqla_column]).limit(1).select_from(tbl) - sql = self.database.compile_sqla_query(qry) - col_desc = get_columns_description(self.database, sql) - is_dttm = col_desc[0]["is_dttm"] + try: + sqla_column = literal_column(expression) + # probe adhoc column type + tbl, _ = self.get_from_clause(template_processor) + qry = sa.select([sqla_column]).limit(1).select_from(tbl) + sql = self.database.compile_sqla_query(qry) + col_desc = get_columns_description(self.database, sql) + is_dttm = col_desc[0]["is_dttm"] + except SupersetGenericDBErrorException as ex: + raise ColumnNotFoundException(message=str(ex)) from ex if ( is_dttm @@ -1181,6 +1192,8 @@ def get_sqla_query( # pylint: disable=too-many-arguments,too-many-locals,too-ma } columns = columns or [] groupby = groupby or [] + rejected_adhoc_filters_columns: List[Union[str, ColumnTyping]] = [] + applied_adhoc_filters_columns: List[Union[str, ColumnTyping]] = [] series_column_names = utils.get_column_names(series_columns or []) # deprecated, to be removed in 2.0 if is_timeseries and timeseries_limit: @@ -1439,9 +1452,14 @@ def get_sqla_query( # pylint: disable=too-many-arguments,too-many-locals,too-ma if flt_col == utils.DTTM_ALIAS and is_timeseries and dttm_col: col_obj = dttm_col elif is_adhoc_column(flt_col): - sqla_col = self.adhoc_column_to_sqla(flt_col) + try: + sqla_col = self.adhoc_column_to_sqla(flt_col) + applied_adhoc_filters_columns.append(flt_col) + except ColumnNotFoundException: + rejected_adhoc_filters_columns.append(flt_col) + continue else: - col_obj = columns_by_name.get(flt_col) + col_obj = columns_by_name.get(cast(str, flt_col)) filter_grain = flt.get("grain") if is_feature_enabled("ENABLE_TEMPLATE_REMOVE_FILTERS"): @@ -1766,8 +1784,27 @@ def get_sqla_query( # pylint: disable=too-many-arguments,too-many-locals,too-ma qry = select([col]).select_from(qry.alias("rowcount_qry")) labels_expected = [label] + filter_columns = [flt.get("col") for flt in filter] if filter else [] + rejected_filter_columns = [ + col + for col in filter_columns + if col + and not is_adhoc_column(col) + and col not in self.column_names + and col not in applied_template_filters + ] + rejected_adhoc_filters_columns + applied_filter_columns = [ + col + for col in filter_columns + if col + and not is_adhoc_column(col) + and (col in self.column_names or col in applied_template_filters) + ] + applied_adhoc_filters_columns + return SqlaQuery( applied_template_filters=applied_template_filters, + rejected_filter_columns=rejected_filter_columns, + applied_filter_columns=applied_filter_columns, cte=cte, extra_cache_keys=extra_cache_keys, labels_expected=labels_expected, @@ -1906,6 +1943,8 @@ def assign_column_label(df: pd.DataFrame) -> Optional[pd.DataFrame]: return QueryResult( applied_template_filters=query_str_ext.applied_template_filters, + applied_filter_columns=query_str_ext.applied_filter_columns, + rejected_filter_columns=query_str_ext.rejected_filter_columns, status=status, df=df, duration=datetime.now() - qry_start_dttm, diff --git a/superset/dao/base.py b/superset/dao/base.py index 28cfdf2cc625e..0cffb0f334598 100644 --- a/superset/dao/base.py +++ b/superset/dao/base.py @@ -65,9 +65,9 @@ def find_by_id( query = cls.base_filter( # pylint: disable=not-callable cls.id_column_name, data_model ).apply(query, None) - id_filter = {cls.id_column_name: model_id} + id_column = getattr(cls.model_cls, cls.id_column_name) try: - return query.filter_by(**id_filter).one_or_none() + return query.filter(id_column == model_id).one_or_none() except StatementError: # can happen if int is passed instead of a string or similar return None diff --git a/superset/dashboards/dao.py b/superset/dashboards/dao.py index 3f0666266f9c8..d66703c91809a 100644 --- a/superset/dashboards/dao.py +++ b/superset/dashboards/dao.py @@ -39,8 +39,8 @@ class DashboardDAO(BaseDAO): model_cls = Dashboard base_filter = DashboardAccessFilter - @staticmethod - def get_by_id_or_slug(id_or_slug: Union[int, str]) -> Dashboard: + @classmethod + def get_by_id_or_slug(cls, id_or_slug: Union[int, str]) -> Dashboard: query = ( db.session.query(Dashboard) .filter(id_or_slug_filter(id_or_slug)) @@ -50,7 +50,7 @@ def get_by_id_or_slug(id_or_slug: Union[int, str]) -> Dashboard: .outerjoin(Dashboard.roles) ) # Apply dashboard base filters - query = DashboardAccessFilter("id", SQLAInterface(Dashboard, db.session)).apply( + query = cls.base_filter("id", SQLAInterface(Dashboard, db.session)).apply( query, None ) dashboard = query.one_or_none() diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py index f23eee9d27126..c74c7ba52ac67 100644 --- a/superset/dashboards/schemas.py +++ b/superset/dashboards/schemas.py @@ -108,7 +108,6 @@ def validate_json_metadata(value: Union[bytes, bytearray, str]) -> None: class DashboardJSONMetadataSchema(Schema): - show_native_filters = fields.Boolean() # native_filter_configuration is for dashboard-native filters native_filter_configuration = fields.List(fields.Dict(), allow_none=True) # chart_configuration for now keeps data about cross-filter scoping for charts diff --git a/superset/databases/commands/create.py b/superset/databases/commands/create.py index 0ed23549608de..eb0582a980fca 100644 --- a/superset/databases/commands/create.py +++ b/superset/databases/commands/create.py @@ -17,6 +17,7 @@ import logging from typing import Any, Dict, List, Optional +from flask import current_app from flask_appbuilder.models.sqla import Model from marshmallow import ValidationError @@ -42,6 +43,7 @@ from superset.extensions import db, event_logger, security_manager logger = logging.getLogger(__name__) +stats_logger = current_app.config["STATS_LOGGER"] class CreateDatabaseCommand(BaseCommand): @@ -91,14 +93,14 @@ def run(self) -> Model: ).run() except (SSHTunnelInvalidError, SSHTunnelCreateFailedError) as ex: event_logger.log_with_context( - action=f"db_creation_failed.{ex.__class__.__name__}", + action=f"db_creation_failed.{ex.__class__.__name__}.ssh_tunnel", engine=self._properties.get("sqlalchemy_uri", "").split(":")[0], ) # So we can show the original message raise ex except Exception as ex: event_logger.log_with_context( - action=f"db_creation_failed.{ex.__class__.__name__}", + action=f"db_creation_failed.{ex.__class__.__name__}.ssh_tunnel", engine=self._properties.get("sqlalchemy_uri", "").split(":")[0], ) raise DatabaseCreateFailedError() from ex @@ -111,6 +113,7 @@ def run(self) -> Model: ) db.session.commit() + except DAOCreateFailedError as ex: db.session.rollback() event_logger.log_with_context( @@ -118,6 +121,10 @@ def run(self) -> Model: engine=database.db_engine_spec.__name__, ) raise DatabaseCreateFailedError() from ex + + if ssh_tunnel: + stats_logger.incr("db_creation_success.ssh_tunnel") + return database def validate(self) -> None: diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py index 63ee7d7f9dcd7..a3647034dcf66 100644 --- a/superset/db_engine_specs/base.py +++ b/superset/db_engine_specs/base.py @@ -354,6 +354,8 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods # This set will give the keywords for data limit statements # to consider for the engines with TOP SQL parsing top_keywords: Set[str] = {"TOP"} + # A set of disallowed connection query parameters + disallow_uri_query_params: Set[str] = set() force_column_alias_quotes = False arraysize = 0 @@ -1724,6 +1726,19 @@ def get_public_information(cls) -> Dict[str, Any]: "disable_ssh_tunneling": cls.disable_ssh_tunneling, } + @classmethod + def validate_database_uri(cls, sqlalchemy_uri: URL) -> None: + """ + Validates a database SQLAlchemy URI per engine spec. + Use this to implement a final validation for unwanted connection configuration + + :param sqlalchemy_uri: + """ + if existing_disallowed := cls.disallow_uri_query_params.intersection( + sqlalchemy_uri.query + ): + raise ValueError(f"Forbidden query parameter(s): {existing_disallowed}") + # schema for adding a database by providing parameters instead of the # full SQLAlchemy URI diff --git a/superset/db_engine_specs/mysql.py b/superset/db_engine_specs/mysql.py index b873daff75602..348b3287e35d4 100644 --- a/superset/db_engine_specs/mysql.py +++ b/superset/db_engine_specs/mysql.py @@ -173,6 +173,7 @@ class MySQLEngineSpec(BaseEngineSpec, BasicParametersMixin): {}, ), } + disallow_uri_query_params = {"local_infile"} @classmethod def convert_dttm( diff --git a/superset/db_engine_specs/trino.py b/superset/db_engine_specs/trino.py index 82c7566f3939d..0a18a3e3e8fe2 100644 --- a/superset/db_engine_specs/trino.py +++ b/superset/db_engine_specs/trino.py @@ -46,6 +46,7 @@ class TrinoEngineSpec(PrestoBaseEngineSpec): engine = "trino" engine_name = "Trino" + allows_alias_to_source_column = False @classmethod def extra_table_metadata( diff --git a/superset/examples/configs/charts/Video_Game_Sales_Filter.yaml b/superset/examples/configs/charts/Video_Game_Sales_Filter.yaml index 1136ab25bdd05..6c76d53e8eabb 100644 --- a/superset/examples/configs/charts/Video_Game_Sales_Filter.yaml +++ b/superset/examples/configs/charts/Video_Game_Sales_Filter.yaml @@ -42,7 +42,7 @@ params: label: Publisher multiple: true searchAllOptions: false - granularity_sqla: Year + granularity_sqla: year queryFields: {} time_range: No filter url_params: diff --git a/superset/examples/configs/dashboards/COVID_Vaccine_Dashboard.yaml b/superset/examples/configs/dashboards/COVID_Vaccine_Dashboard.yaml index a954e63a0272f..363077aebe43c 100644 --- a/superset/examples/configs/dashboards/COVID_Vaccine_Dashboard.yaml +++ b/superset/examples/configs/dashboards/COVID_Vaccine_Dashboard.yaml @@ -318,15 +318,15 @@ metadata: COUNT(*): "#D1C6BC" filter_scopes: "3965": - Country_Name: + country_name: scope: - ROOT_ID immune: [] - Product_Category: + product_category: scope: - ROOT_ID immune: [] - Clinical Stage: + clinical_stage: scope: - ROOT_ID immune: [] diff --git a/superset/examples/configs/dashboards/FCC_New_Coder_Survey_2018.yaml b/superset/examples/configs/dashboards/FCC_New_Coder_Survey_2018.yaml index f7cfedd84d2d4..2e97e6b576a39 100644 --- a/superset/examples/configs/dashboards/FCC_New_Coder_Survey_2018.yaml +++ b/superset/examples/configs/dashboards/FCC_New_Coder_Survey_2018.yaml @@ -717,7 +717,7 @@ metadata: color_scheme: supersetColors filter_scopes: '1387': - Ethnic Minority: + ethnic_minority: scope: - TAB-AsMaxdYL_t immune: [] @@ -725,7 +725,7 @@ metadata: scope: - ROOT_ID immune: [] - Developer Type: + developer_type: scope: - ROOT_ID immune: [] diff --git a/superset/examples/configs/dashboards/Sales_Dashboard.yaml b/superset/examples/configs/dashboards/Sales_Dashboard.yaml index a6ce2947b8214..3efea3af2599b 100644 --- a/superset/examples/configs/dashboards/Sales_Dashboard.yaml +++ b/superset/examples/configs/dashboards/Sales_Dashboard.yaml @@ -386,12 +386,12 @@ metadata: refresh_frequency: 0 default_filters: '{"671": {"__time_range": "No filter"}}' filter_scopes: - '671': - ProductLine: + "671": + product_line: scope: - TAB-4fthLQmdX immune: [] - DealSize: + deal_size: scope: - ROOT_ID immune: [] diff --git a/superset/examples/configs/dashboards/Video_Game_Sales.yaml b/superset/examples/configs/dashboards/Video_Game_Sales.yaml index e3bd5a5acda23..958d32b0696b7 100644 --- a/superset/examples/configs/dashboards/Video_Game_Sales.yaml +++ b/superset/examples/configs/dashboards/Video_Game_Sales.yaml @@ -366,20 +366,20 @@ metadata: timed_refresh_immune_slices: [] expanded_slices: {} refresh_frequency: 0 - default_filters: '{"3547": {"Platform": ["PS", "PS2", "PS3", "XB", "X360"], "__time_range": + default_filters: '{"3547": {"platform": ["PS", "PS2", "PS3", "XB", "X360"], "__time_range": "No filter"}}' color_scheme: supersetColors filter_scopes: - '3547': - Platform: + "3547": + platform: scope: - TAB-2_QXp8aNq immune: [] - Genre: + genre: scope: - ROOT_ID immune: [] - Publisher: + publisher: scope: - ROOT_ID immune: [] diff --git a/superset/exceptions.py b/superset/exceptions.py index 963bf966820d5..cee15be376394 100644 --- a/superset/exceptions.py +++ b/superset/exceptions.py @@ -270,3 +270,7 @@ class SupersetCancelQueryException(SupersetException): class QueryNotFoundException(SupersetException): status = 404 + + +class ColumnNotFoundException(SupersetException): + status = 404 diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index cda0651456b9f..b911461e9c865 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -18,6 +18,7 @@ import logging import os +import sys from typing import Any, Callable, Dict, TYPE_CHECKING import wtforms_json @@ -52,7 +53,7 @@ from superset.security import SupersetSecurityManager from superset.superset_typing import FlaskResponse from superset.tags.core import register_sqla_event_listeners -from superset.utils.core import pessimistic_connection_handling +from superset.utils.core import is_test, pessimistic_connection_handling from superset.utils.log import DBEventLogger, get_event_logger_from_cfg_value if TYPE_CHECKING: @@ -458,7 +459,7 @@ def init_app_in_ctx(self) -> None: self.init_views() def check_secret_key(self) -> None: - if self.config["SECRET_KEY"] == CHANGE_ME_SECRET_KEY: + def log_default_secret_key_warning() -> None: top_banner = 80 * "-" + "\n" + 36 * " " + "WARNING\n" + 80 * "-" bottom_banner = 80 * "-" + "\n" + 80 * "-" logger.warning(top_banner) @@ -471,6 +472,19 @@ def check_secret_key(self) -> None: ) logger.warning(bottom_banner) + if self.config["SECRET_KEY"] == CHANGE_ME_SECRET_KEY: + if ( + self.superset_app.debug + or self.superset_app.config["TESTING"] + or is_test() + ): + logger.warning("Debug mode identified with default secret key") + log_default_secret_key_warning() + return + log_default_secret_key_warning() + logger.error("Refusing to start due to insecure SECRET_KEY") + sys.exit(1) + def init_app(self) -> None: """ Main entry point which will delegate to other methods in diff --git a/superset/migrations/versions/2023-02-28_14-46_c0a3ea245b61_remove_show_native_filters.py b/superset/migrations/versions/2023-02-28_14-46_c0a3ea245b61_remove_show_native_filters.py new file mode 100644 index 0000000000000..d1c4197b532eb --- /dev/null +++ b/superset/migrations/versions/2023-02-28_14-46_c0a3ea245b61_remove_show_native_filters.py @@ -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. +"""remove_show_native_filters + +Revision ID: c0a3ea245b61 +Revises: f3c2d8ec8595 +Create Date: 2023-02-28 14:46:59.597847 + +""" + +# revision identifiers, used by Alembic. +revision = "c0a3ea245b61" +down_revision = "f3c2d8ec8595" + +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 Dashboard(Base): + __tablename__ = "dashboards" + + id = sa.Column(sa.Integer, primary_key=True) + json_metadata = sa.Column(sa.Text) + + +def upgrade(): + bind = op.get_bind() + session = db.Session(bind=bind) + + for dashboard in session.query(Dashboard).all(): + try: + json_metadata = json.loads(dashboard.json_metadata) + + if "show_native_filters" in json_metadata: + del json_metadata["show_native_filters"] + dashboard.json_metadata = json.dumps(json_metadata) + except Exception: # pylint: disable=broad-except + pass + + session.commit() + session.close() + + +def downgrade(): + pass diff --git a/superset/migrations/versions/2023-03-05_10-06_d0ac08bb5b83_invert_horizontal_bar_chart_order.py b/superset/migrations/versions/2023-03-05_10-06_d0ac08bb5b83_invert_horizontal_bar_chart_order.py new file mode 100644 index 0000000000000..6003c70d69fff --- /dev/null +++ b/superset/migrations/versions/2023-03-05_10-06_d0ac08bb5b83_invert_horizontal_bar_chart_order.py @@ -0,0 +1,126 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""invert_horizontal_bar_chart_order + +Revision ID: d0ac08bb5b83 +Revises: c0a3ea245b61 +Create Date: 2023-03-05 10:06:23.250310 + +""" + +# revision identifiers, used by Alembic. +revision = "d0ac08bb5b83" +down_revision = "c0a3ea245b61" + +import json + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import and_, Column, Integer, String, Text +from sqlalchemy.ext.declarative import declarative_base + +from superset import db + +Base = declarative_base() + +ORIENTATION = "horizontal" +CHART_TYPE = "echarts_timeseries_bar" + + +class Slice(Base): + """Declarative class to do query in upgrade""" + + __tablename__ = "slices" + id = Column(Integer, primary_key=True) + viz_type = Column(String(250)) + params = Column(Text) + + +def upgrade(): + bind = op.get_bind() + session = db.Session(bind=bind) + + slices = ( + session.query(Slice) + .filter( + and_( + Slice.viz_type == CHART_TYPE, + Slice.params.like("%x_axis_sort%"), + Slice.params.like("%x_axis_sort_asc%"), + Slice.params.like(f"%{ORIENTATION}%"), + ) + ) + .all() + ) + changes = 0 + for slc in slices: + try: + params = json.loads(slc.params) + orientation = params.get("orientation") + x_axis_sort = params.get("x_axis_sort") + x_axis_sort_asc = params.get("x_axis_sort_asc", None) + if orientation == ORIENTATION and x_axis_sort: + changes += 1 + params["x_axis_sort_asc"] = not x_axis_sort_asc + slc.params = json.dumps(params, sort_keys=True) + except Exception as e: + print(e) + print(f"Parsing params for slice {slc.id} failed.") + pass + + session.commit() + session.close() + if changes: + print(f"Updated {changes} bar chart sort orders.") + + +def downgrade(): + bind = op.get_bind() + session = db.Session(bind=bind) + + slices = ( + session.query(Slice) + .filter( + and_( + Slice.viz_type == CHART_TYPE, + Slice.params.like("%x_axis_sort%"), + Slice.params.like("%x_axis_sort_asc%"), + Slice.params.like(f"%{ORIENTATION}%"), + ) + ) + .all() + ) + changes = 0 + for slc in slices: + try: + params = json.loads(slc.params) + orientation = params.get("orientation") + x_axis_sort = params.get("x_axis_sort") + x_axis_sort_asc = params.pop("x_axis_sort_asc", None) + if orientation == ORIENTATION and x_axis_sort: + changes += 1 + params["x_axis_sort_asc"] = not x_axis_sort_asc + slc.params = json.dumps(params, sort_keys=True) + except Exception as e: + print(e) + print(f"Parsing params for slice {slc.id} failed.") + pass + + session.commit() + session.close() + if changes: + print(f"Updated {changes} bar chart sort orders.") diff --git a/superset/models/core.py b/superset/models/core.py index ac7cc517ef0dc..9c67a2efa6d2b 100755 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -424,6 +424,8 @@ def _get_sqla_engine( sqlalchemy_url = make_url_safe( sqlalchemy_uri if sqlalchemy_uri else self.sqlalchemy_uri_decrypted ) + self.db_engine_spec.validate_database_uri(sqlalchemy_url) + sqlalchemy_url = self.db_engine_spec.adjust_database_uri(sqlalchemy_url, schema) effective_username = self.get_effective_user(sqlalchemy_url) # If using MySQL or Presto for example, will set url.username diff --git a/superset/models/helpers.py b/superset/models/helpers.py index 5cc80576d0bcd..0c52465caac1d 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -80,6 +80,7 @@ from superset.sql_parse import has_table_query, insert_rls, ParsedQuery, sanitize_clause from superset.superset_typing import ( AdhocMetric, + Column as ColumnTyping, FilterValue, FilterValues, Metric, @@ -545,6 +546,8 @@ def __init__( # pylint: disable=too-many-arguments query: str, duration: timedelta, applied_template_filters: Optional[List[str]] = None, + applied_filter_columns: Optional[List[ColumnTyping]] = None, + rejected_filter_columns: Optional[List[ColumnTyping]] = None, status: str = QueryStatus.SUCCESS, error_message: Optional[str] = None, errors: Optional[List[Dict[str, Any]]] = None, @@ -555,6 +558,8 @@ def __init__( # pylint: disable=too-many-arguments self.query = query self.duration = duration self.applied_template_filters = applied_template_filters or [] + self.applied_filter_columns = applied_filter_columns or [] + self.rejected_filter_columns = rejected_filter_columns or [] self.status = status self.error_message = error_message self.errors = errors or [] @@ -1646,7 +1651,7 @@ def get_sqla_query( # pylint: disable=too-many-arguments,too-many-locals,too-ma elif utils.is_adhoc_column(flt_col): sqla_col = self.adhoc_column_to_sqla(flt_col) # type: ignore else: - col_obj = columns_by_name.get(flt_col) + col_obj = columns_by_name.get(cast(str, flt_col)) filter_grain = flt.get("grain") if is_feature_enabled("ENABLE_TEMPLATE_REMOVE_FILTERS"): diff --git a/superset/utils/core.py b/superset/utils/core.py index 06f2f63df1797..9185cbe2fc233 100644 --- a/superset/utils/core.py +++ b/superset/utils/core.py @@ -221,7 +221,7 @@ class AdhocFilterClause(TypedDict, total=False): class QueryObjectFilterClause(TypedDict, total=False): - col: str + col: Column op: str # pylint: disable=invalid-name val: Optional[FilterValues] grain: Optional[str] @@ -1089,7 +1089,7 @@ def simple_filter_to_adhoc( "expressionType": "SIMPLE", "comparator": filter_clause.get("val"), "operator": filter_clause["op"], - "subject": filter_clause["col"], + "subject": cast(str, filter_clause["col"]), } if filter_clause.get("isExtra"): result["isExtra"] = True diff --git a/superset/utils/webdriver.py b/superset/utils/webdriver.py index e678587029dbc..05dbee674052d 100644 --- a/superset/utils/webdriver.py +++ b/superset/utils/webdriver.py @@ -103,11 +103,9 @@ def find_unexpected_errors(driver: WebDriver) -> List[str]: f"arguments[0].innerHTML = '{error_as_html}'", alert_div ) except WebDriverException: - logger.warning( - "Failed to update error messages using alert_div", exc_info=True - ) + logger.exception("Failed to update error messages using alert_div") except WebDriverException: - logger.warning("Failed to capture unexpected errors", exc_info=True) + logger.exception("Failed to capture unexpected errors") return error_messages @@ -142,7 +140,7 @@ def create(self) -> WebDriver: options.add_argument(arg) kwargs.update(current_app.config["WEBDRIVER_CONFIGURATION"]) - logger.info("Init selenium driver") + logger.debug("Init selenium driver") return driver_class(**kwargs) @@ -178,29 +176,53 @@ def get_screenshot( sleep(selenium_headstart) try: - logger.debug("Wait for the presence of %s", element_name) - element = WebDriverWait(driver, self._screenshot_locate_wait).until( - EC.presence_of_element_located((By.CLASS_NAME, element_name)) - ) + try: + # page didn't load + logger.debug( + "Wait for the presence of %s at url: %s", element_name, url + ) + element = WebDriverWait(driver, self._screenshot_locate_wait).until( + EC.presence_of_element_located((By.CLASS_NAME, element_name)) + ) + except TimeoutException as ex: + logger.exception("Selenium timed out requesting url %s", url) + raise ex - logger.debug("Wait for chart containers to draw") - WebDriverWait(driver, self._screenshot_locate_wait).until( - EC.visibility_of_all_elements_located( - (By.CLASS_NAME, "slice_container") + try: + # chart containers didn't render + logger.debug("Wait for chart containers to draw at url: %s", url) + WebDriverWait(driver, self._screenshot_locate_wait).until( + EC.visibility_of_all_elements_located( + (By.CLASS_NAME, "slice_container") + ) ) - ) + except TimeoutException as ex: + logger.exception( + "Selenium timed out waiting for chart containers to draw at url %s", + url, + ) + raise ex - logger.debug("Wait for loading element of charts to be gone") - WebDriverWait(driver, self._screenshot_load_wait).until_not( - EC.presence_of_all_elements_located((By.CLASS_NAME, "loading")) - ) + try: + # charts took too long to load + logger.debug( + "Wait for loading element of charts to be gone at url: %s", url + ) + WebDriverWait(driver, self._screenshot_load_wait).until_not( + EC.presence_of_all_elements_located((By.CLASS_NAME, "loading")) + ) + except TimeoutException as ex: + logger.exception( + "Selenium timed out waiting for charts to load at url %s", url + ) + raise ex selenium_animation_wait = current_app.config[ "SCREENSHOT_SELENIUM_ANIMATION_WAIT" ] logger.debug("Wait %i seconds for chart animation", selenium_animation_wait) sleep(selenium_animation_wait) - logger.info( + logger.debug( "Taking a PNG screenshot of url %s as user %s", url, user.username, @@ -217,17 +239,18 @@ def get_screenshot( ) img = element.screenshot_as_png - except TimeoutException: - logger.warning("Selenium timed out requesting url %s", url, exc_info=True) + # raise again for the finally block, but handled above + pass except StaleElementReferenceException: - logger.error( + logger.exception( "Selenium got a stale element while requesting url %s", url, - exc_info=True, ) - except WebDriverException as ex: - logger.error(ex, exc_info=True) + except WebDriverException: + logger.exception( + "Encountered an unexpected error when requeating url %s", url + ) finally: self.destroy(driver, current_app.config["SCREENSHOT_SELENIUM_RETRIES"]) return img diff --git a/superset/views/base.py b/superset/views/base.py index 6e28745821ee2..ec74b8ccdb3a0 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -188,6 +188,7 @@ def generate_download_headers( def deprecated( eol_version: str = "3.0.0", + new_target: Optional[str] = None, ) -> Callable[[Callable[..., FlaskResponse]], Callable[..., FlaskResponse]]: """ A decorator to set an API endpoint from SupersetView has deprecated. @@ -196,13 +197,19 @@ def deprecated( def _deprecated(f: Callable[..., FlaskResponse]) -> Callable[..., FlaskResponse]: def wraps(self: "BaseSupersetView", *args: Any, **kwargs: Any) -> FlaskResponse: - logger.warning( + messsage = ( "%s.%s " - "This API endpoint is deprecated and will be removed in version %s", + "This API endpoint is deprecated and will be removed in version %s" + ) + logger_args = [ self.__class__.__name__, f.__name__, eol_version, - ) + ] + if new_target: + messsage += " . Use the following API endpoint instead: %s" + logger_args.append(new_target) + logger.warning(messsage, *logger_args) return f(self, *args, **kwargs) return functools.update_wrapper(wraps, f) diff --git a/superset/views/core.py b/superset/views/core.py index 88e94aa2037ef..c6365bbbce0c4 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -211,7 +211,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods @has_access_api @event_logger.log_this @expose("/datasources/") - @deprecated() + @deprecated(new_target="api/v1/dataset/") def datasources(self) -> FlaskResponse: return self.json_response( sorted( @@ -505,7 +505,7 @@ def generate_json( @expose("/slice_json/") @etag_cache() @check_resource_permissions(check_slice_perms) - @deprecated() + @deprecated(new_target="/api/v1/chart//data/") def slice_json(self, slice_id: int) -> FlaskResponse: form_data, slc = get_form_data(slice_id, use_slice_data=True) if not slc: @@ -529,7 +529,7 @@ def slice_json(self, slice_id: int) -> FlaskResponse: @has_access_api @event_logger.log_this @expose("/annotation_json/") - @deprecated() + @deprecated(new_target="/api/v1/chart//data/") def annotation_json( # pylint: disable=no-self-use self, layer_id: int ) -> FlaskResponse: @@ -999,7 +999,10 @@ def explore( @has_access_api @event_logger.log_this @expose("/filter////") - @deprecated() + @deprecated( + new_target="/api/v1/datasource//" + "/column//values/" + ) def filter( # pylint: disable=no-self-use self, datasource_type: str, datasource_id: int, column: str ) -> FlaskResponse: @@ -1143,7 +1146,7 @@ def save_or_overwrite_slice( @event_logger.log_this @expose("/tables///") @expose("/tables////") - @deprecated() + @deprecated(new_target="api/v1/database//tables/") def tables( # pylint: disable=no-self-use self, db_id: int, @@ -1352,7 +1355,7 @@ def add_slices( # pylint: disable=no-self-use @has_access_api @event_logger.log_this @expose("/testconn", methods=["POST", "GET"]) - @deprecated() + @deprecated(new_target="/api/v1/database/test_connection/") def testconn(self) -> FlaskResponse: # pylint: disable=no-self-use """Tests a sqla connection""" db_name = request.json.get("name") @@ -1441,7 +1444,7 @@ def get_user_activity_access_error(user_id: int) -> Optional[FlaskResponse]: @has_access_api @event_logger.log_this @expose("/recent_activity//", methods=["GET"]) - @deprecated() + @deprecated(new_target="/api/v1/log/recent_activity//") def recent_activity(self, user_id: int) -> FlaskResponse: """Recent activity (actions) for a given user""" error_obj = self.get_user_activity_access_error(user_id) @@ -1462,7 +1465,7 @@ def recent_activity(self, user_id: int) -> FlaskResponse: @has_access_api @event_logger.log_this @expose("/available_domains/", methods=["GET"]) - @deprecated() + @deprecated(new_target="/api/v1/available_domains/") def available_domains(self) -> FlaskResponse: # pylint: disable=no-self-use """ Returns the list of available Superset Webserver domains (if any) @@ -1477,7 +1480,7 @@ def available_domains(self) -> FlaskResponse: # pylint: disable=no-self-use @has_access_api @event_logger.log_this @expose("/fave_dashboards_by_username//", methods=["GET"]) - @deprecated() + @deprecated(new_target="api/v1/dashboard/favorite_status/") def fave_dashboards_by_username(self, username: str) -> FlaskResponse: """This lets us use a user's username to pull favourite dashboards""" user = security_manager.find_user(username=username) @@ -1487,7 +1490,7 @@ def fave_dashboards_by_username(self, username: str) -> FlaskResponse: @has_access_api @event_logger.log_this @expose("/fave_dashboards//", methods=["GET"]) - @deprecated() + @deprecated(new_target="api/v1/dashboard/favorite_status/") def fave_dashboards(self, user_id: int) -> FlaskResponse: error_obj = self.get_user_activity_access_error(user_id) if error_obj: @@ -1524,7 +1527,7 @@ def fave_dashboards(self, user_id: int) -> FlaskResponse: @has_access_api @event_logger.log_this @expose("/created_dashboards//", methods=["GET"]) - @deprecated() + @deprecated(new_target="api/v1/dashboard/") def created_dashboards(self, user_id: int) -> FlaskResponse: error_obj = self.get_user_activity_access_error(user_id) if error_obj: @@ -1609,7 +1612,7 @@ def user_slices(self, user_id: Optional[int] = None) -> FlaskResponse: @event_logger.log_this @expose("/created_slices", methods=["GET"]) @expose("/created_slices//", methods=["GET"]) - @deprecated() + @deprecated(new_target="api/v1/chart/") def created_slices(self, user_id: Optional[int] = None) -> FlaskResponse: """List of slices created by this user""" if not user_id: @@ -1926,7 +1929,7 @@ def log(self) -> FlaskResponse: # pylint: disable=no-self-use @has_access @expose("/get_or_create_table/", methods=["POST"]) @event_logger.log_this - @deprecated() + @deprecated(new_target="api/v1/dataset/get_or_create/") def sqllab_table_viz(self) -> FlaskResponse: # pylint: disable=no-self-use """Gets or creates a table object with attributes passed to the API. @@ -2041,7 +2044,9 @@ def sqllab_viz(self) -> FlaskResponse: # pylint: disable=no-self-use @has_access @expose("/extra_table_metadata////") @event_logger.log_this - @deprecated() + @deprecated( + new_target="api/v1/database//table_extra///" + ) def extra_table_metadata( # pylint: disable=no-self-use self, database_id: int, table_name: str, schema: str ) -> FlaskResponse: @@ -2100,7 +2105,7 @@ def theme(self) -> FlaskResponse: @has_access_api @expose("/results//") @event_logger.log_this - @deprecated() + @deprecated(new_target="/api/v1/sqllab/results/") def results(self, key: str) -> FlaskResponse: return self.results_exec(key) @@ -2222,7 +2227,7 @@ def results_exec(key: str) -> FlaskResponse: on_giveup=lambda details: db.session.rollback(), max_tries=5, ) - @deprecated() + @deprecated(new_target="/api/v1/query/stop") def stop_query(self) -> FlaskResponse: client_id = request.form.get("client_id") query = db.session.query(Query).filter_by(client_id=client_id).one() @@ -2251,7 +2256,7 @@ def stop_query(self) -> FlaskResponse: @has_access_api @event_logger.log_this @expose("/validate_sql_json/", methods=["POST", "GET"]) - @deprecated() + @deprecated(new_target="/api/v1/database//validate_sql/") def validate_sql_json( # pylint: disable=too-many-locals,no-self-use self, @@ -2324,7 +2329,7 @@ def validate_sql_json( @handle_api_exception @event_logger.log_this @expose("/sql_json/", methods=["POST"]) - @deprecated() + @deprecated(new_target="/api/v1/sqllab/execute/") def sql_json(self) -> FlaskResponse: errors = SqlJsonPayloadSchema().validate(request.json) if errors: @@ -2402,7 +2407,7 @@ def _create_response_from_execution_context( # pylint: disable=invalid-name, no @has_access @event_logger.log_this @expose("/csv/") - @deprecated() + @deprecated(new_target="/api/v1/sqllab/export/") def csv(self, client_id: str) -> FlaskResponse: # pylint: disable=no-self-use """Download the query results as csv.""" logger.info("Exporting CSV file [%s]", client_id) @@ -2476,7 +2481,7 @@ def csv(self, client_id: str) -> FlaskResponse: # pylint: disable=no-self-use @has_access @event_logger.log_this @expose("/fetch_datasource_metadata") - @deprecated() + @deprecated(new_target="api/v1/database//table///") def fetch_datasource_metadata(self) -> FlaskResponse: # pylint: disable=no-self-use """ Fetch the datasource metadata. @@ -2499,7 +2504,7 @@ def fetch_datasource_metadata(self) -> FlaskResponse: # pylint: disable=no-self @event_logger.log_this @expose("/queries/") @expose("/queries/") - @deprecated() + @deprecated(new_target="api/v1/query/updated_since") def queries(self, last_updated_ms: Union[float, int]) -> FlaskResponse: """ Get the updated queries. @@ -2531,7 +2536,7 @@ def queries_exec(last_updated_ms: Union[float, int]) -> FlaskResponse: @has_access @event_logger.log_this @expose("/search_queries") - @deprecated() + @deprecated(new_target="api/v1/query/") def search_queries(self) -> FlaskResponse: # pylint: disable=no-self-use """ Search for previously run sqllab queries. Used for Sqllab Query Search diff --git a/superset/viz.py b/superset/viz.py index d8f0dc342b127..87f8bbee36950 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -154,7 +154,8 @@ def __init__( self.status: Optional[str] = None self.error_msg = "" self.results: Optional[QueryResult] = None - self.applied_template_filters: List[str] = [] + self.applied_filter_columns: List[Column] = [] + self.rejected_filter_columns: List[Column] = [] self.errors: List[Dict[str, Any]] = [] self.force = force self._force_cached = force_cached @@ -288,7 +289,8 @@ def get_df(self, query_obj: Optional[QueryObjectDict] = None) -> pd.DataFrame: # The datasource here can be different backend but the interface is common self.results = self.datasource.query(query_obj) - self.applied_template_filters = self.results.applied_template_filters or [] + self.applied_filter_columns = self.results.applied_filter_columns or [] + self.rejected_filter_columns = self.results.rejected_filter_columns or [] self.query = self.results.query self.status = self.results.status self.errors = self.results.errors @@ -492,25 +494,21 @@ def get_payload(self, query_obj: Optional[QueryObjectDict] = None) -> VizPayload if "df" in payload: del payload["df"] - filters = self.form_data.get("filters", []) - filter_columns = [flt.get("col") for flt in filters] - columns = set(self.datasource.column_names) - applied_template_filters = self.applied_template_filters or [] + applied_filter_columns = self.applied_filter_columns or [] + rejected_filter_columns = self.rejected_filter_columns or [] applied_time_extras = self.form_data.get("applied_time_extras", {}) applied_time_columns, rejected_time_columns = utils.get_time_filter_status( self.datasource, applied_time_extras ) payload["applied_filters"] = [ - {"column": get_column_name(col)} - for col in filter_columns - if is_adhoc_column(col) or col in columns or col in applied_template_filters + {"column": get_column_name(col)} for col in applied_filter_columns ] + applied_time_columns payload["rejected_filters"] = [ - {"reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE, "column": col} - for col in filter_columns - if not is_adhoc_column(col) - and col not in columns - and col not in applied_template_filters + { + "reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE, + "column": get_column_name(col), + } + for col in rejected_filter_columns ] + rejected_time_columns if df is not None: payload["colnames"] = list(df.columns) @@ -535,8 +533,11 @@ def get_df_payload( # pylint: disable=too-many-statements try: df = cache_value["df"] self.query = cache_value["query"] - self.applied_template_filters = cache_value.get( - "applied_template_filters", [] + self.applied_filter_columns = cache_value.get( + "applied_filter_columns", [] + ) + self.rejected_filter_columns = cache_value.get( + "rejected_filter_columns", [] ) self.status = QueryStatus.SUCCESS is_loaded = True diff --git a/tests/integration_tests/charts/api_tests.py b/tests/integration_tests/charts/api_tests.py index 965a9c137ba87..38fa1b7a6c9d3 100644 --- a/tests/integration_tests/charts/api_tests.py +++ b/tests/integration_tests/charts/api_tests.py @@ -609,17 +609,21 @@ def test_update_chart_new_owner_not_admin(self): """ Chart API: Test update set new owner implicitly adds logged in owner """ - gamma = self.get_user("gamma") + gamma = self.get_user("gamma_no_csv") alpha = self.get_user("alpha") - chart_id = self.insert_chart("title", [alpha.id], 1).id - chart_data = {"slice_name": "title1_changed", "owners": [gamma.id]} - self.login(username="alpha") + chart_id = self.insert_chart("title", [gamma.id], 1).id + chart_data = { + "slice_name": (new_name := "title1_changed"), + "owners": [alpha.id], + } + self.login(username=gamma.username) uri = f"api/v1/chart/{chart_id}" rv = self.put_assert_metric(uri, chart_data, "put") - self.assertEqual(rv.status_code, 200) + assert rv.status_code == 200 model = db.session.query(Slice).get(chart_id) - self.assertIn(alpha, model.owners) - self.assertIn(gamma, model.owners) + assert model.slice_name == new_name + assert alpha in model.owners + assert gamma in model.owners db.session.delete(model) db.session.commit() diff --git a/tests/integration_tests/charts/data/api_tests.py b/tests/integration_tests/charts/data/api_tests.py index 66151362ff1d4..83fb7281fbc74 100644 --- a/tests/integration_tests/charts/data/api_tests.py +++ b/tests/integration_tests/charts/data/api_tests.py @@ -56,6 +56,7 @@ AnnotationType, get_example_default_schema, AdhocMetricExpressionType, + ExtraFiltersReasonType, ) from superset.utils.database import get_example_database, get_main_database from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType @@ -73,6 +74,12 @@ "when gender = 'girl' then 'female' else 'other' end", } +INCOMPATIBLE_ADHOC_COLUMN_FIXTURE: AdhocColumn = { + "hasCustomLabel": True, + "label": "exciting_or_boring", + "sqlExpression": "case when genre = 'Action' then 'Exciting' else 'Boring' end", +} + class BaseTestChartDataApi(SupersetTestCase): query_context_payload_template = None @@ -1059,6 +1066,33 @@ def test_chart_data_with_adhoc_column(self): assert unique_genders == {"male", "female"} assert result["applied_filters"] == [{"column": "male_or_female"}] + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") + def test_chart_data_with_incompatible_adhoc_column(self): + """ + Chart data API: Test query with adhoc column that fails to run on this dataset + """ + self.login(username="admin") + request_payload = get_query_context("birth_names") + request_payload["queries"][0]["columns"] = [ADHOC_COLUMN_FIXTURE] + request_payload["queries"][0]["filters"] = [ + {"col": INCOMPATIBLE_ADHOC_COLUMN_FIXTURE, "op": "IN", "val": ["Exciting"]}, + {"col": ADHOC_COLUMN_FIXTURE, "op": "IN", "val": ["male", "female"]}, + ] + rv = self.post_assert_metric(CHART_DATA_URI, request_payload, "data") + response_payload = json.loads(rv.data.decode("utf-8")) + result = response_payload["result"][0] + data = result["data"] + assert {column for column in data[0].keys()} == {"male_or_female", "sum__num"} + unique_genders = {row["male_or_female"] for row in data} + assert unique_genders == {"male", "female"} + assert result["applied_filters"] == [{"column": "male_or_female"}] + assert result["rejected_filters"] == [ + { + "column": "exciting_or_boring", + "reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE, + } + ] + @pytest.fixture() def physical_query_context(physical_dataset) -> Dict[str, Any]: diff --git a/tests/unit_tests/db_engine_specs/test_mysql.py b/tests/unit_tests/db_engine_specs/test_mysql.py index 4562e497c6e61..a512e71a97f67 100644 --- a/tests/unit_tests/db_engine_specs/test_mysql.py +++ b/tests/unit_tests/db_engine_specs/test_mysql.py @@ -33,6 +33,7 @@ TINYINT, TINYTEXT, ) +from sqlalchemy.engine.url import make_url from superset.utils.core import GenericDataType from tests.unit_tests.db_engine_specs.utils import ( @@ -99,6 +100,25 @@ def test_convert_dttm( assert_convert_dttm(spec, target_type, expected_result, dttm) +@pytest.mark.parametrize( + "sqlalchemy_uri,error", + [ + ("mysql://user:password@host/db1?local_infile=1", True), + ("mysql://user:password@host/db1?local_infile=0", True), + ("mysql://user:password@host/db1", False), + ], +) +def test_validate_database_uri(sqlalchemy_uri: str, error: bool) -> None: + from superset.db_engine_specs.mysql import MySQLEngineSpec + + url = make_url(sqlalchemy_uri) + if error: + with pytest.raises(ValueError): + MySQLEngineSpec.validate_database_uri(url) + return + MySQLEngineSpec.validate_database_uri(url) + + @patch("sqlalchemy.engine.Engine.connect") def test_get_cancel_query_id(engine_mock: Mock) -> None: from superset.db_engine_specs.mysql import MySQLEngineSpec diff --git a/tests/unit_tests/fixtures/assets_configs.py b/tests/unit_tests/fixtures/assets_configs.py index 6e78d9e562d10..73bc5921ec42f 100644 --- a/tests/unit_tests/fixtures/assets_configs.py +++ b/tests/unit_tests/fixtures/assets_configs.py @@ -174,7 +174,6 @@ "default_filters": "{}", "color_scheme": "supersetColors", "label_colors": {}, - "show_native_filters": True, "color_scheme_domain": [], "shared_label_colors": {}, "cross_filters_enabled": False, @@ -251,7 +250,6 @@ "default_filters": "{}", "color_scheme": "supersetColors", "label_colors": {}, - "show_native_filters": True, "color_scheme_domain": [], "shared_label_colors": {}, },