diff --git a/.github/workflows/chromatic-master.yml b/.github/workflows/chromatic-master.yml index 67a9dfac69d2..11e07e6c5ab3 100644 --- a/.github/workflows/chromatic-master.yml +++ b/.github/workflows/chromatic-master.yml @@ -53,6 +53,8 @@ jobs: # Job steps steps: - uses: actions/checkout@v3 + with: + fetch-depth: 0 # 👈 Required to retrieve git history - name: Install dependencies run: npm ci working-directory: superset-frontend diff --git a/.github/workflows/docker-ephemeral-env.yml b/.github/workflows/docker-ephemeral-env.yml index 544c1c8b1f42..e3ca7473d2aa 100644 --- a/.github/workflows/docker-ephemeral-env.yml +++ b/.github/workflows/docker-ephemeral-env.yml @@ -1,4 +1,4 @@ -name: Push ephmereral env image +name: Push ephemeral env image on: workflow_run: @@ -17,14 +17,14 @@ jobs: id: check shell: bash run: | - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - if [ -n "${{ (secrets.AWS_ACCESS_KEY_ID != '' && - secrets.AWS_ACCESS_KEY_ID != '' && - secrets.AWS_SECRET_ACCESS_KEY != '' && - secrets.AWS_SECRET_ACCESS_KEY != '') || '' }}" ]; then - echo "has-secrets=1" >> "$GITHUB_OUTPUT" - fi + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + if [ -n "${{ (secrets.AWS_ACCESS_KEY_ID != '' && + secrets.AWS_ACCESS_KEY_ID != '' && + secrets.AWS_SECRET_ACCESS_KEY != '' && + secrets.AWS_SECRET_ACCESS_KEY != '') || '' }}" ]; then + echo "has-secrets=1" >> "$GITHUB_OUTPUT" + fi docker_ephemeral_env: needs: config @@ -33,66 +33,66 @@ jobs: runs-on: ubuntu-latest steps: - - name: 'Download artifact' - uses: actions/github-script@v3.1.0 - with: - script: | - const artifacts = await github.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: ${{ github.event.workflow_run.id }}, - }); + - name: "Download artifact" + uses: actions/github-script@v3.1.0 + with: + script: | + const artifacts = await github.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{ github.event.workflow_run.id }}, + }); - core.info('*** artifacts') - core.info(JSON.stringify(artifacts)) + core.info('*** artifacts') + core.info(JSON.stringify(artifacts)) - const matchArtifact = artifacts.data.artifacts.filter((artifact) => { - return artifact.name == "build" - })[0]; - if(!matchArtifact) return core.setFailed("Build artifacts not found") + const matchArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "build" + })[0]; + if(!matchArtifact) return core.setFailed("Build artifacts not found") - const download = await github.actions.downloadArtifact({ - owner: context.repo.owner, - repo: context.repo.repo, - artifact_id: matchArtifact.id, - archive_format: 'zip', - }); - var fs = require('fs'); - fs.writeFileSync('${{github.workspace}}/build.zip', Buffer.from(download.data)); + const download = await github.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + var fs = require('fs'); + fs.writeFileSync('${{github.workspace}}/build.zip', Buffer.from(download.data)); - - run: unzip build.zip + - run: unzip build.zip - - name: Display downloaded files (debug) - run: ls -la + - name: Display downloaded files (debug) + run: ls -la - - name: Get SHA - id: get-sha - run: echo "::set-output name=sha::$(cat ./SHA)" + - name: Get SHA + id: get-sha + run: echo "::set-output name=sha::$(cat ./SHA)" - - name: Get PR - id: get-pr - run: echo "::set-output name=num::$(cat ./PR-NUM)" + - name: Get PR + id: get-pr + run: echo "::set-output name=num::$(cat ./PR-NUM)" - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: us-west-2 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-west-2 - - name: Login to Amazon ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@v1 + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 - - name: Load, tag and push image to ECR - id: push-image - env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} - ECR_REPOSITORY: superset-ci - SHA: ${{ steps.get-sha.outputs.sha }} - IMAGE_TAG: pr-${{ steps.get-pr.outputs.num }} - run: | - docker load < $SHA.tar.gz - docker tag $SHA $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG - docker tag $SHA $ECR_REGISTRY/$ECR_REPOSITORY:$SHA - docker push -a $ECR_REGISTRY/$ECR_REPOSITORY + - name: Load, tag and push image to ECR + id: push-image + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + ECR_REPOSITORY: superset-ci + SHA: ${{ steps.get-sha.outputs.sha }} + IMAGE_TAG: pr-${{ steps.get-pr.outputs.num }} + run: | + docker load < $SHA.tar.gz + docker tag $SHA $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker tag $SHA $ECR_REGISTRY/$ECR_REPOSITORY:$SHA + docker push -a $ECR_REGISTRY/$ECR_REPOSITORY diff --git a/RESOURCES/FEATURE_FLAGS.md b/RESOURCES/FEATURE_FLAGS.md index 6e6bf16e5fc2..60f58a5feac1 100644 --- a/RESOURCES/FEATURE_FLAGS.md +++ b/RESOURCES/FEATURE_FLAGS.md @@ -31,11 +31,10 @@ These features are considered **unfinished** and should only be used on developm - DASHBOARD_CACHE - DASHBOARD_NATIVE_FILTERS_SET - DISABLE_DATASET_SOURCE_EDIT -- DRILL_TO_DETAIL +- DRILL_BY - ENABLE_ADVANCED_DATA_TYPES - ENABLE_EXPLORE_JSON_CSRF_PROTECTION - ENABLE_TEMPLATE_REMOVE_FILTERS -- HORIZONTAL_FILTER_BAR - KV_STORE - PRESTO_EXPAND_DATA - REMOVE_SLICE_LEVEL_LABEL_COLORS @@ -54,10 +53,15 @@ These features are **finished** but currently being tested. They are usable, but - CONFIRM_DASHBOARD_DIFF - DASHBOARD_EDIT_CHART_IN_NEW_TAB - DASHBOARD_FILTERS_EXPERIMENTAL -- DASHBOARD_NATIVE_FILTERS +- DASHBOARD_VIRTUALIZATION +- DRILL_BY +- DRILL_TO_DETAIL - DYNAMIC_PLUGINS: [(docs)](https://superset.apache.org/docs/installation/running-on-kubernetes) - ENABLE_JAVASCRIPT_CONTROLS +- ESTIMATE_QUERY_COST +- GENERIC_CHART_AXES - GLOBAL_ASYNC_QUERIES [(docs)](https://github.com/apache/superset/blob/master/CONTRIBUTING.md#async-chart-queries) +- HORIZONTAL_FILTER_BAR - RLS_IN_SQLLAB - SSH_TUNNELING [(docs)](https://superset.apache.org/docs/installation/setup-ssh-tunneling) - USE_ANALAGOUS_COLORS @@ -65,7 +69,7 @@ These features are **finished** but currently being tested. They are usable, but ## Stable -These features flags are **safe for production** and have been tested. +These features flags are **safe for production**. They have been tested and will be supported for the foreseeable future. [//]: # "PLEASE KEEP THE LIST SORTED ALPHABETICALLY" @@ -73,6 +77,7 @@ These features flags are **safe for production** and have been tested. - ALLOW_ADHOC_SUBQUERY - DASHBOARD_CROSS_FILTERS - DASHBOARD_RBAC [(docs)](https://superset.apache.org/docs/creating-charts-dashboards/first-dashboard#manage-access-to-dashboards) +- DATAPANEL_CLOSED_BY_DEFAULT - DISABLE_LEGACY_DATASOURCE_EDITOR - DRUID_JOINS - EMBEDDABLE_CHARTS @@ -93,4 +98,5 @@ These features flags currently default to True and **will be removed in a future [//]: # "PLEASE KEEP THE LIST SORTED ALPHABETICALLY" +- DASHBOARD_NATIVE_FILTERS - GENERIC_CHART_AXES diff --git a/UPDATING.md b/UPDATING.md index 9afd8d353fd1..12e69b3738a5 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -53,6 +53,7 @@ assists people when migrating to a new version. - [22798](https://github.com/apache/superset/pull/22798): To make the welcome page more relevant in production environments, the last tab on the welcome page has been changed from to feature all charts/dashboards the user has access to (previously only examples were shown). To keep current behavior unchanged, add the following to your `superset_config.py`: `WELCOME_PAGE_LAST_TAB = "examples"` - [22328](https://github.com/apache/superset/pull/22328): For deployments that have enabled the "THUMBNAILS" feature flag, the function that calculates dashboard digests has been updated to consider additional properties to more accurately identify changes in the dashboard metadata. This change will invalidate all currently cached dashboard thumbnails. - [21765](https://github.com/apache/superset/pull/21765): For deployments that have enabled the "ALERT_REPORTS" feature flag, Gamma users will no longer have read and write access to Alerts & Reports by default. To give Gamma users the ability to schedule reports from the Dashboard and Explore view like before, create an additional role with "can read on ReportSchedule" and "can write on ReportSchedule" permissions. To further give Gamma users access to the "Alerts & Reports" menu and CRUD view, add "menu access on Manage" and "menu access on Alerts & Report" permissions to the role. +- [22325](https://github.com/apache/superset/pull/22325): "RLS_FORM_QUERY_REL_FIELDS" is replaced by "RLS_BASE_RELATED_FIELD_FILTERS" feature flag. Its value format stays same. ### Potential Downtime diff --git a/docs/docs/databases/ocient.mdx b/docs/docs/databases/ocient.mdx new file mode 100644 index 000000000000..ce0b70b74fb8 --- /dev/null +++ b/docs/docs/databases/ocient.mdx @@ -0,0 +1,37 @@ +--- +title: Ocient DB +hide_title: true +sidebar_position: 20 +version: 1 +--- + +## Ocient DB + +The recommended connector library for Ocient is [sqlalchemy-ocient](https://pypi.org/project/sqlalchemy-ocient). + +## Install the Ocient Driver + +``` +pip install sqlalchemy-ocient +``` + +## Connecting to Ocient + +The format of the Ocient DSN is: + +```shell +ocient://user:password@[host][:port][/database][?param1=value1&...] +``` + +The DSN for connecting to an `exampledb` database hosted at `examplehost:4050` with TLS enabled is: +```shell +ocient://admin:abc123@examplehost:4050/exampledb?tls=on +``` + +**NOTE**: You must enter the `user` and `password` credentials. `host` defaults to localhost, +port defaults to 4050, database defaults to `system` and `tls` defaults +to `unverified`. + +## User Access Control + +Make sure the user has privileges to access and use all required databases, schemas, tables, views, and warehouses, as the Ocient SQLAlchemy engine does not test for user or role rights by default. diff --git a/docs/docs/frequently-asked-questions.mdx b/docs/docs/frequently-asked-questions.mdx index 779f6c8c8dc7..78fd2a0b8d8a 100644 --- a/docs/docs/frequently-asked-questions.mdx +++ b/docs/docs/frequently-asked-questions.mdx @@ -24,8 +24,8 @@ will do its own _GROUP BY_ and doing the work twice might slow down performance. Whether you use a table or a view, the important factor is whether your database is fast enough to serve it in an interactive fashion to provide a good user experience in Superset. -However, if you are using the SQL Lab, there is no such limitation, you can write sql query to join -multiple tables as long as your db account has access to the tables. +However, if you are using SQL Lab, there is no such limitation. You can write SQL queries to join +multiple tables as long as your database account has access to the tables. ### How BIG can my datasource be? diff --git a/docs/docs/miscellaneous/native-filter-migration.mdx b/docs/docs/miscellaneous/native-filter-migration.mdx new file mode 100644 index 000000000000..b231c049b237 --- /dev/null +++ b/docs/docs/miscellaneous/native-filter-migration.mdx @@ -0,0 +1,103 @@ +--- +title: Migrating from Legacy to Native Filters +sidebar_position: 5 +version: 1 +--- + +## + +The `superset native-filters` CLI command group—somewhat akin to an Alembic migration— +comprises of a number of sub-commands which allows administrators to upgrade/downgrade +existing dashboards which use the legacy filter-box charts—in combination with the +filter scopes/filter mapping—to use the native filter dashboard component. + +Even though both legacy and native filters can coexist the overall user experience (UX) +is substandard as the already convoluted filter space becomes overly complex. After +enabling the `DASHBOARD_NATIVE_FILTERS` it is strongly advised to run the migration ASAP to +ensure users are not exposed to the hybrid state. + +### Upgrading + +The + +``` +superset native-filters upgrade +``` + +command—which provides the option to target either specific dashboard(s) or all +dashboards—migrates the legacy filters to native filters. + +Specifically, the command performs the following: + +- Replaces every filter-box chart within the dashboard with a markdown element which +provides a link to the deprecated chart. This preserves the layout whilst simultaneously +providing context to help owners review/verify said change. +- Migrates the filter scopes/filter mappings to the native filter configuration. + +#### Quality Control + +Dashboard owners should: + +- Verify that the filter behavior is correct. +- Consolidate any conflicting/redundant filters—this previously may not have been +obvious given the embedded nature of the legacy filters and/or the non-optimal UX of the +legacy filter mapping (scopes and immunity). +- Rename the filters—which may not be uniquely named—to provide the necessary context +which previously was likely provided by both the location of the filter-box and the +corresponding filter-box title. + +Dashboard owners may: + +- Remove† the markdown elements from their dashboards and adjust the layout accordingly. + +† Note removing the markdown elements—which contain metadata relating to the replaced +chart—prevents the dashboard from being fully restored and thus this operation should +only be performed if it is evident that a downgrade is not necessary. + +### Downgrading + +Similarly the + +``` +superset native-filters downgrade +``` + +command reverses said migration, i.e., restores the dashboard to the previous state. + + +### Cleanup + +The ability to downgrade/reverse the migration requires temporary storage of the +dashboard metadata—relating to both positional composition and filter configuration. + +Once the upgrade has been verified it is recommended to run the + +``` +superset native-filters cleanup +``` + +command—which provides the option to target either specific dashboard(s) or all +dashboards. Note this operation is irreversible. + +Specifically, the command performs the following: + +- Removes the temporary dashboard metadata. +- Deletes the filter-box charts associated with the dashboard†. + +† Note the markdown elements will still remain however the link to the referenced filter-box +chart will no longer be valid. + +Finally, the + +``` +superset native-filers cleanup --all +``` + +command will additionally delete all filter-box charts, irrespective of whether they +were ever associated with a dashboard. + +#### Quality Control + +Dashboard owners should: + +- Remove the markdown elements from their dashboards and adjust the layout accordingly. diff --git a/requirements/base.txt b/requirements/base.txt index 3a5ec607fe9f..5e54e1e06d23 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -40,12 +40,15 @@ click==8.0.4 # apache-superset # celery # click-didyoumean + # click-option-group # click-plugins # click-repl # flask # flask-appbuilder click-didyoumean==0.3.0 # via celery +click-option-group==0.5.5 + # via apache-superset click-plugins==1.1.1 # via celery click-repl==0.2.0 @@ -64,6 +67,8 @@ cryptography==39.0.1 # via # apache-superset # paramiko +deprecated==1.2.13 + # via limits deprecation==2.1.0 # via apache-superset dnspython==2.1.0 @@ -78,6 +83,7 @@ flask==2.1.3 # flask-caching # flask-compress # flask-jwt-extended + # flask-limiter # flask-login # flask-migrate # flask-sqlalchemy @@ -92,6 +98,8 @@ flask-compress==1.13 # via apache-superset flask-jwt-extended==4.3.1 # via flask-appbuilder +flask-limiter==3.3.0 + # via flask-appbuilder flask-login==0.6.0 # via # apache-superset @@ -128,6 +136,8 @@ humanize==3.11.0 # via apache-superset idna==3.2 # via email-validator +importlib-metadata==6.0.0 + # via flask isodate==0.6.0 # via apache-superset itsdangerous==2.1.1 @@ -144,10 +154,14 @@ kombu==5.2.4 # via celery korean-lunar-calendar==0.2.1 # via holidays +limits==3.2.0 + # via flask-limiter mako==1.1.4 # via alembic markdown==3.3.4 # via apache-superset +markdown-it-py==2.2.0 + # via rich markupsafe==2.1.1 # via # jinja2 @@ -162,6 +176,8 @@ marshmallow-enum==1.5.1 # via flask-appbuilder marshmallow-sqlalchemy==0.23.1 # via flask-appbuilder +mdurl==0.1.2 + # via markdown-it-py msgpack==1.0.2 # via apache-superset numpy==1.23.5 @@ -169,10 +185,13 @@ numpy==1.23.5 # apache-superset # pandas # pyarrow +ordered-set==4.1.0 + # via flask-limiter packaging==21.3 # via # bleach # deprecation + # limits pandas==1.5.3 # via apache-superset paramiko==2.11.0 @@ -191,6 +210,8 @@ pyarrow==10.0.1 # via apache-superset pycparser==2.20 # via cffi +pygments==2.14.0 + # via rich pyjwt==2.4.0 # via # apache-superset @@ -232,8 +253,12 @@ pyyaml==5.4.1 # apispec redis==3.5.3 # via apache-superset +rich==13.3.1 + # via flask-limiter selenium==3.141.0 # via apache-superset +shortid==0.1.2 + # via apache-superset simplejson==3.17.3 # via apache-superset six==1.16.0 @@ -269,7 +294,11 @@ sshtunnel==0.4.0 tabulate==0.8.9 # via apache-superset typing-extensions==4.4.0 - # via apache-superset + # via + # apache-superset + # flask-limiter + # limits + # rich urllib3==1.26.6 # via selenium vine==5.0.0 @@ -286,6 +315,8 @@ werkzeug==2.1.2 # flask # flask-jwt-extended # flask-login +wrapt==1.12.1 + # via deprecated wtforms==2.3.3 # via # apache-superset @@ -296,6 +327,8 @@ wtforms-json==0.3.3 # via apache-superset xlsxwriter==3.0.7 # via apache-superset +zipp==3.15.0 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/development.txt b/requirements/development.txt index 47fe7a17372d..aa92fcfda4d8 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -80,8 +80,6 @@ pure-sasl==0.6.2 # via thrift-sasl pydruid==0.6.5 # via apache-superset -pygments==2.12.0 - # via ipython pyhive[hive]==0.6.5 # via apache-superset pyinstrument==4.0.2 diff --git a/requirements/integration.txt b/requirements/integration.txt index 59c619a38602..c11f956c68d0 100644 --- a/requirements/integration.txt +++ b/requirements/integration.txt @@ -30,7 +30,7 @@ packaging==21.3 pep517==0.11.0 # via build pip-compile-multi==2.6.2 - # via -r integration.in + # via -r requirements/integration.in pip-tools==6.8.0 # via pip-compile-multi platformdirs==2.6.2 @@ -38,7 +38,7 @@ platformdirs==2.6.2 pluggy==0.13.1 # via tox pre-commit==3.2.2 - # via -r integration.in + # via -r requirements/integration.in py==1.10.0 # via tox pyparsing==3.0.6 @@ -50,11 +50,11 @@ six==1.16.0 toml==0.10.2 # via tox tomli==1.2.1 - # via pep517 + # via build toposort==1.6 # via pip-compile-multi tox==3.25.1 - # via -r integration.in + # via -r requirements/integration.in virtualenv==20.17.1 # via # pre-commit diff --git a/setup.py b/setup.py index c6850070a0a7..664d320b22e5 100644 --- a/setup.py +++ b/setup.py @@ -77,6 +77,7 @@ def get_git_sha() -> str: "cachelib>=0.4.1,<0.5", "celery>=5.2.2, <6.0.0", "click>=8.0.3", + "click-option-group", "colorama", "croniter>=0.3.28", "cron-descriptor", @@ -114,6 +115,7 @@ def get_git_sha() -> str: "PyJWT>=2.4.0, <3.0", "redis", "selenium>=3.141.0", + "shortid", "sshtunnel>=0.4.0, <0.5", "simplejson>=3.15.0", "slack_sdk>=3.1.1, <4", @@ -162,6 +164,10 @@ def get_git_sha() -> str: "kylin": ["kylinpy>=2.8.1, <2.9"], "mssql": ["pymssql>=2.1.4, <2.2"], "mysql": ["mysqlclient>=2.1.0, <3"], + "ocient": [ + "sqlalchemy-ocient>=1.0.0", + "pyocient>=1.0.15", + ], "oracle": ["cx-Oracle>8.0.0, <8.1"], "pinot": ["pinotdb>=0.3.3, <0.4"], "postgres": ["psycopg2-binary==2.9.5"], diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/editmode.test.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/editmode.test.ts index d4e51046bc4d..113cf92d564f 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/editmode.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/editmode.test.ts @@ -734,7 +734,8 @@ describe('Dashboard edit', () => { cy.getBySel('dashboard-charts-filter-search-input').clear(); }); - it('should disable the Save button when undoing', () => { + // TODO fix this test! This was the #1 flaky test as of 4/21/23 according to cypress dashboard. + xit('should disable the Save button when undoing', () => { cy.get('[role="checkbox"]').click(); dragComponent('Unicode Cloud', 'card-title', false); cy.getBySel('header-save-button').should('be.enabled'); diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/utils.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/utils.ts index 0b8776b06c1a..44322e1c42ce 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/utils.ts +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/utils.ts @@ -163,10 +163,6 @@ export function interceptDatasets() { cy.intercept('GET', `/api/v1/dashboard/*/datasets`).as('getDatasets'); } -export function interceptDashboardasync() { - cy.intercept('GET', `/dashboardasync/api/read*`).as('getDashboardasync'); -} - export function interceptFilterState() { cy.intercept('POST', `/api/v1/dashboard/*/filter_state*`).as( 'postFilterState', diff --git a/superset-frontend/cypress-base/cypress/integration/dataset/dataset_list.test.ts b/superset-frontend/cypress-base/cypress/integration/dataset/dataset_list.test.ts index e78c328ec510..6bf0419cdda2 100644 --- a/superset-frontend/cypress-base/cypress/integration/dataset/dataset_list.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/dataset/dataset_list.test.ts @@ -24,7 +24,7 @@ describe('Dataset list', () => { cy.visit(DATASET_LIST_PATH); }); - it('should open Explore on dataset name click', () => { + xit('should open Explore on dataset name click', () => { cy.intercept('**/api/v1/explore/**').as('explore'); cy.get('[data-test="listview-table"] [data-test="internal-link"]') .contains('birth_names') diff --git a/superset-frontend/cypress-base/cypress/integration/explore/utils.ts b/superset-frontend/cypress-base/cypress/integration/explore/utils.ts index 15e7dcba1b6f..04cf1f181998 100644 --- a/superset-frontend/cypress-base/cypress/integration/explore/utils.ts +++ b/superset-frontend/cypress-base/cypress/integration/explore/utils.ts @@ -17,10 +17,7 @@ * under the License. */ -import { - interceptGet as interceptDashboardGet, - interceptDashboardasync, -} from '../dashboard/utils'; +import { interceptGet as interceptDashboardGet } from '../dashboard/utils'; export function interceptFiltering() { cy.intercept('GET', `/api/v1/chart/?q=*`).as('filtering'); @@ -61,12 +58,10 @@ export function setFilter(filter: string, option: string) { export function saveChartToDashboard(dashboardName: string) { interceptDashboardGet(); - interceptDashboardasync(); interceptUpdate(); interceptExploreGet(); cy.getBySel('query-save-button').click(); - cy.wait('@getDashboardasync'); cy.getBySelLike('chart-modal').should('be.visible'); cy.get( '[data-test="save-chart-modal-select-dashboard-form"] [aria-label="Select a dashboard"]', diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 1e5a73b39343..2d40096fa8a8 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -57203,9 +57203,9 @@ "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==" }, "node_modules/vm2": { - "version": "3.9.15", - "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.15.tgz", - "integrity": "sha512-XqNqknHGw2avJo13gbIwLNZUumvrSHc9mLqoadFZTpo3KaNEJoe1I0lqTFhRXmXD7WkLyG01aaraXdXT0pa4ag==", + "version": "3.9.17", + "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.17.tgz", + "integrity": "sha512-AqwtCnZ/ERcX+AVj9vUsphY56YANXxRuqMb7GsDtAr0m0PcQX3u0Aj3KWiXM0YAHy7i6JEeHrwOnwXbGYgRpAw==", "dev": true, "dependencies": { "acorn": "^8.7.0", @@ -106734,9 +106734,9 @@ "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==" }, "vm2": { - "version": "3.9.15", - "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.15.tgz", - "integrity": "sha512-XqNqknHGw2avJo13gbIwLNZUumvrSHc9mLqoadFZTpo3KaNEJoe1I0lqTFhRXmXD7WkLyG01aaraXdXT0pa4ag==", + "version": "3.9.17", + "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.17.tgz", + "integrity": "sha512-AqwtCnZ/ERcX+AVj9vUsphY56YANXxRuqMb7GsDtAr0m0PcQX3u0Aj3KWiXM0YAHy7i6JEeHrwOnwXbGYgRpAw==", "dev": true, "requires": { "acorn": "^8.7.0", diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/fixtures.ts b/superset-frontend/packages/superset-ui-chart-controls/src/fixtures.ts index 71c4dd31189b..1e5152b2bd90 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/fixtures.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/fixtures.ts @@ -20,7 +20,7 @@ import { DatasourceType } from '@superset-ui/core'; import { Dataset } from './types'; export const TestDataset: Dataset = { - column_format: {}, + column_formats: {}, columns: [ { advanced_data_type: undefined, diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts index 67582523bc31..c26f53b6a209 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts @@ -67,7 +67,7 @@ export interface Dataset { type: DatasourceType; columns: ColumnMeta[]; metrics: Metric[]; - column_format: Record; + column_formats: Record; verbose_map: Record; main_dttm_col: string; // eg. ['["ds", true]', 'ds [asc]'] diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/utils/columnChoices.test.tsx b/superset-frontend/packages/superset-ui-chart-controls/test/utils/columnChoices.test.tsx index 59f4796a44d2..e27fa9512084 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/utils/columnChoices.test.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/test/utils/columnChoices.test.tsx @@ -42,7 +42,7 @@ describe('columnChoices()', () => { }, ], verbose_map: {}, - column_format: { fiz: 'NUMERIC', about: 'STRING', foo: 'DATE' }, + column_formats: { fiz: 'NUMERIC', about: 'STRING', foo: 'DATE' }, datasource_name: 'my_datasource', description: 'this is my datasource', }), diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/utils/defineSavedMetrics.test.tsx b/superset-frontend/packages/superset-ui-chart-controls/test/utils/defineSavedMetrics.test.tsx index 48b000ed17ff..765412d592c2 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/utils/defineSavedMetrics.test.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/test/utils/defineSavedMetrics.test.tsx @@ -39,7 +39,7 @@ describe('defineSavedMetrics', () => { time_grain_sqla: 'P1D', columns: [], verbose_map: {}, - column_format: {}, + column_formats: {}, datasource_name: 'my_datasource', description: 'this is my datasource', }; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx index 516255de0cbd..202d62756965 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx @@ -205,7 +205,7 @@ export default function EchartsTimeseries({ const pointerEvent = eventParams.event.event; const values = [ ...(eventParams.name ? [eventParams.name] : []), - ...labelMap[seriesName], + ...(labelMap[seriesName] ?? []), ]; if (data && xAxis.type === AxisType.time) { drillToDetailFilters.push({ diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.js b/superset-frontend/src/SqlLab/actions/sqlLab.js index 5d79801be4ef..6b7802a4cff9 100644 --- a/superset-frontend/src/SqlLab/actions/sqlLab.js +++ b/superset-frontend/src/SqlLab/actions/sqlLab.js @@ -50,7 +50,6 @@ export const EXPAND_TABLE = 'EXPAND_TABLE'; export const COLLAPSE_TABLE = 'COLLAPSE_TABLE'; export const QUERY_EDITOR_SETDB = 'QUERY_EDITOR_SETDB'; export const QUERY_EDITOR_SET_SCHEMA = 'QUERY_EDITOR_SET_SCHEMA'; -export const QUERY_EDITOR_SET_TABLE_OPTIONS = 'QUERY_EDITOR_SET_TABLE_OPTIONS'; export const QUERY_EDITOR_SET_TITLE = 'QUERY_EDITOR_SET_TITLE'; export const QUERY_EDITOR_SET_AUTORUN = 'QUERY_EDITOR_SET_AUTORUN'; export const QUERY_EDITOR_SET_SQL = 'QUERY_EDITOR_SET_SQL'; @@ -80,6 +79,7 @@ export const STOP_QUERY = 'STOP_QUERY'; export const REQUEST_QUERY_RESULTS = 'REQUEST_QUERY_RESULTS'; export const QUERY_SUCCESS = 'QUERY_SUCCESS'; export const QUERY_FAILED = 'QUERY_FAILED'; +export const CLEAR_INACTIVE_QUERIES = 'CLEAR_INACTIVE_QUERIES'; export const CLEAR_QUERY_RESULTS = 'CLEAR_QUERY_RESULTS'; export const REMOVE_DATA_PREVIEW = 'REMOVE_DATA_PREVIEW'; export const CHANGE_DATA_PREVIEW_ID = 'CHANGE_DATA_PREVIEW_ID'; @@ -219,6 +219,10 @@ export function estimateQueryCost(queryEditor) { }; } +export function clearInactiveQueries() { + return { type: CLEAR_INACTIVE_QUERIES }; +} + export function startQuery(query) { Object.assign(query, { id: query.id ? query.id : shortid.generate(), @@ -952,10 +956,6 @@ export function queryEditorSetSchema(queryEditor, schema) { }; } -export function queryEditorSetTableOptions(queryEditor, options) { - return { type: QUERY_EDITOR_SET_TABLE_OPTIONS, queryEditor, options }; -} - export function queryEditorSetAutorun(queryEditor, autorun) { return function (dispatch) { const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx index d14d532dcd81..b60faacbe043 100644 --- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx +++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx @@ -39,7 +39,7 @@ import { FullSQLEditor as AceEditor, } from 'src/components/AsyncAceEditor'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; -import { useSchemas } from 'src/hooks/apiResources'; +import { useSchemas, useTables } from 'src/hooks/apiResources'; type HotKey = { key: string; @@ -65,9 +65,6 @@ const StyledAceEditor = styled(AceEditor)` // double class is better than !important border: 1px solid ${theme.colors.grayscale.light2}; font-feature-settings: 'liga' off, 'calt' off; - // Fira Code causes problem with Ace under Firefox - font-family: 'Menlo', 'Consolas', 'Courier New', 'Ubuntu Mono', - 'source-code-pro', 'Lucida Console', monospace; &.ace_autocomplete { // Use !important because Ace Editor applies extra CSS at the last second @@ -99,11 +96,19 @@ const AceEditorWrapper = ({ 'dbId', 'sql', 'functionNames', - 'tableOptions', 'validationResult', 'schema', ]); - const { data: schemaOptions } = useSchemas({ dbId: queryEditor.dbId }); + const { data: schemaOptions } = useSchemas({ + ...(autocomplete && { dbId: queryEditor.dbId }), + }); + const { data: tableData } = useTables({ + ...(autocomplete && { + dbId: queryEditor.dbId, + schema: queryEditor.schema, + }), + }); + const currentSql = queryEditor.sql ?? ''; const functionNames = queryEditor.functionNames ?? []; @@ -120,7 +125,7 @@ const AceEditorWrapper = ({ }), [schemaOptions], ); - const tables = queryEditor.tableOptions ?? []; + const tables = tableData?.options ?? []; const [sql, setSql] = useState(currentSql); const [words, setWords] = useState([]); diff --git a/superset-frontend/src/SqlLab/components/App/index.jsx b/superset-frontend/src/SqlLab/components/App/index.jsx index 4689f8ec2191..bbd1bba9aead 100644 --- a/superset-frontend/src/SqlLab/components/App/index.jsx +++ b/superset-frontend/src/SqlLab/components/App/index.jsx @@ -168,7 +168,7 @@ class App extends React.PureComponent { } render() { - const { queries, actions, queriesLastUpdate } = this.props; + const { queries, queriesLastUpdate } = this.props; if (this.state.hash && this.state.hash === '#search') { return window.location.replace('/superset/sqllab/history/'); } @@ -176,7 +176,6 @@ class App extends React.PureComponent { diff --git a/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/EstimateQueryCostButton.test.tsx b/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/EstimateQueryCostButton.test.tsx index 5b2cae174166..38dbbf65a05b 100644 --- a/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/EstimateQueryCostButton.test.tsx +++ b/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/EstimateQueryCostButton.test.tsx @@ -19,7 +19,7 @@ import React from 'react'; import configureStore from 'redux-mock-store'; import thunk from 'redux-thunk'; -import { render } from 'spec/helpers/testing-library'; +import { fireEvent, render } from 'spec/helpers/testing-library'; import { Store } from 'redux'; import { initialState, @@ -90,4 +90,49 @@ describe('EstimateQueryCostButton', () => { expect(queryByText('Estimate selected query cost')).toBeTruthy(); }); + + it('renders estimation error result', async () => { + const { queryByText, getByText } = setup( + {}, + mockStore({ + ...initialState, + sqlLab: { + ...initialState.sqlLab, + queryCostEstimates: { + [defaultQueryEditor.id]: { + error: 'Estimate error', + }, + }, + }, + }), + ); + + expect(queryByText('Estimate cost')).toBeTruthy(); + fireEvent.click(getByText('Estimate cost')); + + expect(queryByText('Estimate error')).toBeTruthy(); + }); + + it('renders estimation success result', async () => { + const { queryByText, getByText } = setup( + {}, + mockStore({ + ...initialState, + sqlLab: { + ...initialState.sqlLab, + queryCostEstimates: { + [defaultQueryEditor.id]: { + completed: true, + cost: [{ 'Total cost': '1.2' }], + }, + }, + }, + }), + ); + + expect(queryByText('Estimate cost')).toBeTruthy(); + fireEvent.click(getByText('Estimate cost')); + + expect(queryByText('Total cost')).toBeTruthy(); + }); }); diff --git a/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/ExploreCtasResultsButton.test.tsx b/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/ExploreCtasResultsButton.test.tsx new file mode 100644 index 000000000000..1f3382505bd0 --- /dev/null +++ b/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/ExploreCtasResultsButton.test.tsx @@ -0,0 +1,95 @@ +/** + * 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 configureStore from 'redux-mock-store'; +import fetchMock from 'fetch-mock'; +import thunk from 'redux-thunk'; +import { fireEvent, render, waitFor } from 'spec/helpers/testing-library'; +import { Store } from 'redux'; +import { SupersetClientClass } from '@superset-ui/core'; +import { initialState } from 'src/SqlLab/fixtures'; + +import ExploreCtasResultsButton, { + ExploreCtasResultsButtonProps, +} from 'src/SqlLab/components/ExploreCtasResultsButton'; + +const middlewares = [thunk]; +const mockStore = configureStore(middlewares); + +const getOrCreateTableEndpoint = `glob:*/superset/get_or_create_table/`; + +const setup = (props: Partial, store?: Store) => + render( + , + { + useRedux: true, + ...(store && { store }), + }, + ); + +describe('ExploreCtasResultsButton', () => { + const postFormSpy = jest.spyOn(SupersetClientClass.prototype, 'postForm'); + postFormSpy.mockImplementation(jest.fn()); + + it('renders', async () => { + const { queryByText } = setup({}, mockStore(initialState)); + + expect(queryByText('Explore')).toBeTruthy(); + }); + + it('visualize results', async () => { + const { getByText } = setup({}, mockStore(initialState)); + + postFormSpy.mockClear(); + fetchMock.reset(); + fetchMock.post(getOrCreateTableEndpoint, { table_id: 1234 }); + + fireEvent.click(getByText('Explore')); + + await waitFor(() => { + expect(postFormSpy).toHaveBeenCalledTimes(1); + expect(postFormSpy).toHaveBeenCalledWith('http://localhost/explore/', { + form_data: + '{"datasource":"1234__table","metrics":["count"],"groupby":[],"viz_type":"table","since":"100 years ago","all_columns":[],"row_limit":1000}', + }); + }); + }); + + it('visualize results fails', async () => { + const { getByText } = setup({}, mockStore(initialState)); + + postFormSpy.mockClear(); + fetchMock.reset(); + fetchMock.post(getOrCreateTableEndpoint, { + status: 500, + body: { message: 'Unexpected all to v1 API' }, + }); + + fireEvent.click(getByText('Explore')); + + await waitFor(() => { + expect(postFormSpy).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/index.tsx b/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/index.tsx index a4c71139c0d3..ef3f8462ee54 100644 --- a/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/index.tsx +++ b/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/index.tsx @@ -29,7 +29,7 @@ import Button from 'src/components/Button'; import { exploreChart } from 'src/explore/exploreUtils'; import { SqlLabRootState } from 'src/SqlLab/types'; -interface ExploreCtasResultsButtonProps { +export interface ExploreCtasResultsButtonProps { table: string; schema?: string | null; dbId: number; diff --git a/superset-frontend/src/SqlLab/components/ExploreResultsButton/ExploreResultsButton.test.jsx b/superset-frontend/src/SqlLab/components/ExploreResultsButton/ExploreResultsButton.test.jsx deleted file mode 100644 index e9f1740517e0..000000000000 --- a/superset-frontend/src/SqlLab/components/ExploreResultsButton/ExploreResultsButton.test.jsx +++ /dev/null @@ -1,67 +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 configureStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; -import { shallow } from 'enzyme'; -import sqlLabReducer from 'src/SqlLab/reducers/index'; -import ExploreResultsButton from 'src/SqlLab/components/ExploreResultsButton'; -import Button from 'src/components/Button'; -import { supersetTheme, ThemeProvider } from '@superset-ui/core'; - -describe('ExploreResultsButton', () => { - const middlewares = [thunk]; - const mockStore = configureStore(middlewares); - const database = { - allows_subquery: true, - }; - const initialState = { - sqlLab: { - ...sqlLabReducer(undefined, {}), - }, - common: { - conf: { SUPERSET_WEBSERVER_TIMEOUT: 45 }, - }, - }; - const store = mockStore(initialState); - const mockedProps = { - database, - onClick() {}, - }; - - const getExploreResultsButtonWrapper = (props = mockedProps) => - shallow( - - - , - ) - .dive() - .dive(); - - it('renders with props', () => { - expect( - React.isValidElement(), - ).toBe(true); - }); - - it('renders a Button', () => { - const wrapper = getExploreResultsButtonWrapper(); - expect(wrapper.find(Button)).toExist(); - }); -}); diff --git a/superset-frontend/src/SqlLab/components/ExploreResultsButton/ExploreResultsButton.test.tsx b/superset-frontend/src/SqlLab/components/ExploreResultsButton/ExploreResultsButton.test.tsx new file mode 100644 index 000000000000..5126299feabd --- /dev/null +++ b/superset-frontend/src/SqlLab/components/ExploreResultsButton/ExploreResultsButton.test.tsx @@ -0,0 +1,51 @@ +/** + * 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, screen } from 'spec/helpers/testing-library'; + +import ExploreResultsButton, { + ExploreResultsButtonProps, +} from 'src/SqlLab/components/ExploreResultsButton'; +import { OnClickHandler } from 'src/components/Button'; + +const setup = ( + onClickFn: OnClickHandler, + props: Partial = {}, +) => + render(, { + useRedux: true, + }); + +describe('ExploreResultsButton', () => { + it('renders', async () => { + const { queryByText } = setup(jest.fn(), { + database: { allows_subquery: true }, + }); + + expect(queryByText('Create Chart')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Create Chart' })).toBeEnabled(); + }); + + it('renders disabled if subquery not allowed', async () => { + const { queryByText } = setup(jest.fn()); + + expect(queryByText('Create Chart')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Create Chart' })).toBeDisabled(); + }); +}); diff --git a/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx b/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx index b3ee748218e6..454f3a26b0f4 100644 --- a/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx +++ b/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx @@ -21,7 +21,7 @@ import { t } from '@superset-ui/core'; import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; import Button, { OnClickHandler } from 'src/components/Button'; -interface ExploreResultsButtonProps { +export interface ExploreResultsButtonProps { database?: { allows_subquery?: boolean; }; diff --git a/superset-frontend/src/SqlLab/components/QueryAutoRefresh/QueryAutoRefresh.test.tsx b/superset-frontend/src/SqlLab/components/QueryAutoRefresh/QueryAutoRefresh.test.tsx index 32bf401f2213..9b2c1feaefda 100644 --- a/superset-frontend/src/SqlLab/components/QueryAutoRefresh/QueryAutoRefresh.test.tsx +++ b/superset-frontend/src/SqlLab/components/QueryAutoRefresh/QueryAutoRefresh.test.tsx @@ -16,15 +16,26 @@ * specific language governing permissions and limitations * under the License. */ +import fetchMock from 'fetch-mock'; import React from 'react'; -import { render } from '@testing-library/react'; +import configureStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { render, waitFor } from 'spec/helpers/testing-library'; +import { + CLEAR_INACTIVE_QUERIES, + REFRESH_QUERIES, +} from 'src/SqlLab/actions/sqlLab'; import QueryAutoRefresh, { isQueryRunning, shouldCheckForQueries, + QUERY_UPDATE_FREQ, } from 'src/SqlLab/components/QueryAutoRefresh'; import { successfulQuery, runningQuery } from 'src/SqlLab/fixtures'; import { QueryDictionary } from 'src/SqlLab/types'; +const middlewares = [thunk]; +const mockStore = configureStore(middlewares); + // NOTE: The uses of @ts-ignore in this file is to enable testing of bad inputs to verify the // function / component handles bad data elegantly describe('QueryAutoRefresh', () => { @@ -34,10 +45,14 @@ describe('QueryAutoRefresh', () => { const successfulQueries: QueryDictionary = {}; successfulQueries[successfulQuery.id] = successfulQuery; - const refreshQueries = jest.fn(); - const queriesLastUpdate = Date.now(); + const refreshApi = 'glob:*/api/v1/query/updated_since?*'; + + afterEach(() => { + fetchMock.reset(); + }); + it('isQueryRunning returns true for valid running query', () => { const running = isQueryRunning(runningQuery); expect(running).toBe(true); @@ -91,43 +106,119 @@ describe('QueryAutoRefresh', () => { ).toBe(false); }); - it('Attempts to refresh when given pending query', () => { + it('Attempts to refresh when given pending query', async () => { + const store = mockStore(); + fetchMock.get(refreshApi, { + result: [ + { + id: runningQuery.id, + status: 'success', + }, + ], + }); + render( , + { useRedux: true, store }, + ); + await waitFor( + () => + expect(store.getActions()).toContainEqual( + expect.objectContaining({ + type: REFRESH_QUERIES, + }), + ), + { timeout: QUERY_UPDATE_FREQ + 100 }, ); - setTimeout(() => { - expect(refreshQueries).toHaveBeenCalled(); - }, 1000); }); - it('Does not fail and attempts to refresh when given pending query and invlaid query', () => { + it('Attempts to clear inactive queries when updated queries are empty', async () => { + const store = mockStore(); + fetchMock.get(refreshApi, { + result: [], + }); + + render( + , + { useRedux: true, store }, + ); + await waitFor( + () => + expect(store.getActions()).toContainEqual( + expect.objectContaining({ + type: CLEAR_INACTIVE_QUERIES, + }), + ), + { timeout: QUERY_UPDATE_FREQ + 100 }, + ); + expect( + store.getActions().filter(({ type }) => type === REFRESH_QUERIES), + ).toHaveLength(0); + expect(fetchMock.calls(refreshApi)).toHaveLength(1); + }); + + it('Does not fail and attempts to refresh when given pending query and invlaid query', async () => { + const store = mockStore(); + fetchMock.get(refreshApi, { + result: [ + { + id: runningQuery.id, + status: 'success', + }, + ], + }); + render( , + { useRedux: true, store }, + ); + await waitFor( + () => + expect(store.getActions()).toContainEqual( + expect.objectContaining({ + type: REFRESH_QUERIES, + }), + ), + { timeout: QUERY_UPDATE_FREQ + 100 }, ); - setTimeout(() => { - expect(refreshQueries).toHaveBeenCalled(); - }, 1000); }); - it('Does NOT Attempt to refresh when given only completed queries', () => { + it('Does NOT Attempt to refresh when given only completed queries', async () => { + const store = mockStore(); + fetchMock.get(refreshApi, { + result: [ + { + id: runningQuery.id, + status: 'success', + }, + ], + }); render( , + { useRedux: true, store }, + ); + await waitFor( + () => + expect(store.getActions()).toContainEqual( + expect.objectContaining({ + type: CLEAR_INACTIVE_QUERIES, + }), + ), + { timeout: QUERY_UPDATE_FREQ + 100 }, ); - setTimeout(() => { - expect(refreshQueries).not.toHaveBeenCalled(); - }, 1000); + expect(fetchMock.calls(refreshApi)).toHaveLength(0); }); }); diff --git a/superset-frontend/src/SqlLab/components/QueryAutoRefresh/index.tsx b/superset-frontend/src/SqlLab/components/QueryAutoRefresh/index.tsx index 2d01e724e247..65a6d11a1d1a 100644 --- a/superset-frontend/src/SqlLab/components/QueryAutoRefresh/index.tsx +++ b/superset-frontend/src/SqlLab/components/QueryAutoRefresh/index.tsx @@ -16,7 +16,8 @@ * specific language governing permissions and limitations * under the License. */ -import { useState } from 'react'; +import { useRef } from 'react'; +import { useDispatch } from 'react-redux'; import { isObject } from 'lodash'; import rison from 'rison'; import { @@ -27,19 +28,18 @@ import { } from '@superset-ui/core'; import { QueryDictionary } from 'src/SqlLab/types'; import useInterval from 'src/SqlLab/utils/useInterval'; +import { + refreshQueries, + clearInactiveQueries, +} from 'src/SqlLab/actions/sqlLab'; -const QUERY_UPDATE_FREQ = 2000; +export const QUERY_UPDATE_FREQ = 2000; const QUERY_UPDATE_BUFFER_MS = 5000; const MAX_QUERY_AGE_TO_POLL = 21600000; const QUERY_TIMEOUT_LIMIT = 10000; -interface RefreshQueriesFunc { - (alteredQueries: any): any; -} - export interface QueryAutoRefreshProps { queries: QueryDictionary; - refreshQueries: RefreshQueriesFunc; queriesLastUpdate: number; } @@ -61,20 +61,22 @@ export const shouldCheckForQueries = (queryList: QueryDictionary): boolean => { function QueryAutoRefresh({ queries, - refreshQueries, queriesLastUpdate, }: QueryAutoRefreshProps) { // We do not want to spam requests in the case of slow connections and potentially receive responses out of order // pendingRequest check ensures we only have one active http call to check for query statuses - const [pendingRequest, setPendingRequest] = useState(false); + const pendingRequestRef = useRef(false); + const cleanInactiveRequestRef = useRef(false); + const dispatch = useDispatch(); const checkForRefresh = () => { - if (!pendingRequest && shouldCheckForQueries(queries)) { + const shouldRequestChecking = shouldCheckForQueries(queries); + if (!pendingRequestRef.current && shouldRequestChecking) { const params = rison.encode({ last_updated_ms: queriesLastUpdate - QUERY_UPDATE_BUFFER_MS, }); - setPendingRequest(true); + pendingRequestRef.current = true; SupersetClient.get({ endpoint: `/api/v1/query/updated_since?q=${params}`, timeout: QUERY_TIMEOUT_LIMIT, @@ -82,19 +84,27 @@ function QueryAutoRefresh({ .then(({ json }) => { if (json) { const jsonPayload = json as { result?: QueryResponse[] }; - const queries = - jsonPayload?.result?.reduce((acc, current) => { - acc[current.id] = current; - return acc; - }, {}) ?? {}; - refreshQueries?.(queries); + if (jsonPayload?.result?.length) { + const queries = + jsonPayload?.result?.reduce((acc, current) => { + acc[current.id] = current; + return acc; + }, {}) ?? {}; + dispatch(refreshQueries(queries)); + } else { + dispatch(clearInactiveQueries()); + } } }) .catch(() => {}) .finally(() => { - setPendingRequest(false); + pendingRequestRef.current = false; }); } + if (!cleanInactiveRequestRef.current && !shouldRequestChecking) { + dispatch(clearInactiveQueries()); + cleanInactiveRequestRef.current = true; + } }; // Solves issue where direct usage of setInterval in function components diff --git a/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx b/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx index b513637b2738..4071b9e2d71d 100644 --- a/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx +++ b/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx @@ -80,7 +80,6 @@ const SaveQuery = ({ 'schema', 'selectedText', 'sql', - 'tableOptions', 'templateParams', ]); const query = useMemo( diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx index 0cacdb86caf0..1298722d5dd2 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx @@ -35,7 +35,6 @@ import { collapseTable, expandTable, queryEditorSetSchema, - queryEditorSetTableOptions, setDatabases, addDangerToast, resetState, @@ -218,15 +217,6 @@ const SqlEditorLeftBar = ({ [dispatch, queryEditor], ); - const handleTablesLoad = useCallback( - (options: Array) => { - if (queryEditor) { - dispatch(queryEditorSetTableOptions(queryEditor, options)); - } - }, - [dispatch, queryEditor], - ); - const handleDbList = useCallback( (result: DatabaseObject) => { dispatch(setDatabases(result)); @@ -256,7 +246,6 @@ const SqlEditorLeftBar = ({ onDbChange={onDbChange} onSchemaChange={handleSchemaChange} onTableSelectChange={onTablesChange} - onTablesLoad={handleTablesLoad} schema={schema} tableValue={selectedTableNames} sqlLabMode diff --git a/superset-frontend/src/SqlLab/fixtures.ts b/superset-frontend/src/SqlLab/fixtures.ts index ba88a41b0acc..cfd15bedbe70 100644 --- a/superset-frontend/src/SqlLab/fixtures.ts +++ b/superset-frontend/src/SqlLab/fixtures.ts @@ -185,7 +185,6 @@ export const defaultQueryEditor = { name: 'Untitled Query 1', schema: 'main', remoteId: null, - tableOptions: [], functionNames: [], hideLeftBar: false, templateParams: '{}', diff --git a/superset-frontend/src/SqlLab/reducers/sqlLab.js b/superset-frontend/src/SqlLab/reducers/sqlLab.js index ff2cb340bbd0..eafc326aaa0d 100644 --- a/superset-frontend/src/SqlLab/reducers/sqlLab.js +++ b/superset-frontend/src/SqlLab/reducers/sqlLab.js @@ -587,18 +587,6 @@ export default function sqlLabReducer(state = {}, action) { ), }; }, - [actions.QUERY_EDITOR_SET_TABLE_OPTIONS]() { - return { - ...state, - ...alterUnsavedQueryEditorState( - state, - { - tableOptions: action.options, - }, - action.queryEditor.id, - ), - }; - }, [actions.QUERY_EDITOR_SET_TITLE]() { return { ...state, @@ -742,6 +730,21 @@ export default function sqlLabReducer(state = {}, action) { } return { ...state, queries: newQueries, queriesLastUpdate }; }, + [actions.CLEAR_INACTIVE_QUERIES]() { + const { queries } = state; + const cleanedQueries = Object.fromEntries( + Object.entries(queries).filter(([, query]) => { + if ( + ['running', 'pending'].includes(query.state) && + query.progress === 0 + ) { + return false; + } + return true; + }), + ); + return { ...state, queries: cleanedQueries }; + }, [actions.SET_USER_OFFLINE]() { return { ...state, offline: action.offline }; }, diff --git a/superset-frontend/src/SqlLab/types.ts b/superset-frontend/src/SqlLab/types.ts index ab63e1c76080..e209be04be50 100644 --- a/superset-frontend/src/SqlLab/types.ts +++ b/superset-frontend/src/SqlLab/types.ts @@ -39,7 +39,6 @@ export interface QueryEditor { autorun: boolean; sql: string; remoteId: number | null; - tableOptions: any[]; functionNames: string[]; validationResult?: { completed: boolean; diff --git a/superset-frontend/src/components/AsyncAceEditor/index.tsx b/superset-frontend/src/components/AsyncAceEditor/index.tsx index dc5a37a61460..297ff7b55222 100644 --- a/superset-frontend/src/components/AsyncAceEditor/index.tsx +++ b/superset-frontend/src/components/AsyncAceEditor/index.tsx @@ -84,6 +84,7 @@ export type AsyncAceEditorOptions = { defaultMode?: AceEditorMode; defaultTheme?: AceEditorTheme; defaultTabSize?: number; + fontFamily?: string; placeholder?: React.ComponentType< PlaceholderProps & Partial > | null; @@ -98,6 +99,7 @@ export default function AsyncAceEditor( defaultMode, defaultTheme, defaultTabSize = 2, + fontFamily = 'Menlo, Consolas, Courier New, Ubuntu Mono, source-code-pro, Lucida Console, monospace', placeholder, }: AsyncAceEditorOptions = {}, ) { @@ -153,6 +155,7 @@ export default function AsyncAceEditor( theme={theme} tabSize={tabSize} defaultValue={defaultValue} + setOptions={{ fontFamily }} {...props} /> ); diff --git a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx index 401c3fa99272..6ab80aad9e7e 100644 --- a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx +++ b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx @@ -234,9 +234,7 @@ const ChartContextMenu = ( } menuItems.push( ) => render( , @@ -133,7 +132,7 @@ test('render disabled menu item for unsupported chart', async () => { }); test('render disabled menu item for supported chart, no filters', async () => { - renderMenu({ filters: [] }); + renderMenu({ drillByConfig: { filters: [], groupbyFieldName: 'groupby' } }); await expectDrillByDisabled('Drill by is not available for this data point'); }); @@ -237,6 +236,6 @@ test('When menu item is clicked, call onSelection with clicked column and drill column_name: 'col1', groupby: true, }, - defaultFilters, + { filters: defaultFilters, groupbyFieldName: 'groupby' }, ); }); diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx index e5b919ff6d78..17e013d29f1f 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx @@ -29,8 +29,8 @@ import { Menu } from 'src/components/Menu'; import { BaseFormData, Behavior, - BinaryQueryObjectFilterClause, Column, + ContextMenuFilters, css, ensureIsArray, getChartMetadataRegistry, @@ -39,6 +39,7 @@ import { } from '@superset-ui/core'; import Icons from 'src/components/Icons'; import { Input } from 'src/components/Input'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; import { cachedSupersetGet, supersetGetCache, @@ -54,12 +55,10 @@ const SHOW_COLUMNS_SEARCH_THRESHOLD = 10; const SEARCH_INPUT_HEIGHT = 48; export interface DrillByMenuItemsProps { - filters?: BinaryQueryObjectFilterClause[]; + drillByConfig?: ContextMenuFilters['drillBy']; formData: BaseFormData & { [key: string]: any }; contextMenuY?: number; submenuIndex?: number; - groupbyFieldName?: string; - adhocFilterFieldName?: string; onSelection?: (...args: any) => void; onClick?: (event: MouseEvent) => void; openNewModal?: boolean; @@ -67,9 +66,7 @@ export interface DrillByMenuItemsProps { } export const DrillByMenuItems = ({ - filters, - groupbyFieldName, - adhocFilterFieldName, + drillByConfig, formData, contextMenuY = 0, submenuIndex = 0, @@ -80,22 +77,22 @@ export const DrillByMenuItems = ({ ...rest }: DrillByMenuItemsProps) => { const theme = useTheme(); + const { addDangerToast } = useToasts(); const [searchInput, setSearchInput] = useState(''); const [dataset, setDataset] = useState(); const [columns, setColumns] = useState([]); const [showModal, setShowModal] = useState(false); const [currentColumn, setCurrentColumn] = useState(); - const handleSelection = useCallback( (event, column) => { onClick(event); - onSelection(column, filters); + onSelection(column, drillByConfig); setCurrentColumn(column); if (openNewModal) { setShowModal(true); } }, - [filters, onClick, onSelection, openNewModal], + [drillByConfig, onClick, onSelection, openNewModal], ); const closeModal = useCallback(() => { setShowModal(false); @@ -107,7 +104,9 @@ export const DrillByMenuItems = ({ setSearchInput(''); }, [columns.length]); - const hasDrillBy = ensureIsArray(filters).length && groupbyFieldName; + const hasDrillBy = + ensureIsArray(drillByConfig?.filters).length && + drillByConfig?.groupbyFieldName; const handlesDimensionContextMenu = useMemo( () => @@ -130,9 +129,9 @@ export const DrillByMenuItems = ({ .filter(column => column.groupby) .filter( column => - !ensureIsArray(formData[groupbyFieldName]).includes( - column.column_name, - ) && + !ensureIsArray( + formData[drillByConfig.groupbyFieldName ?? ''], + ).includes(column.column_name) && column.column_name !== formData.x_axis && ensureIsArray(excludedColumns)?.every( excludedCol => @@ -143,12 +142,14 @@ export const DrillByMenuItems = ({ }) .catch(() => { supersetGetCache.delete(`/api/v1/dataset/${datasetId}`); + addDangerToast(t('Failed to load dimensions for drill by')); }); } }, [ + addDangerToast, excludedColumns, formData, - groupbyFieldName, + drillByConfig?.groupbyFieldName, handlesDimensionContextMenu, hasDrillBy, ]); @@ -266,10 +267,8 @@ export const DrillByMenuItems = ({ {showModal && ( diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx index f0768253e0ad..95e2f6027f21 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx @@ -82,6 +82,7 @@ const renderModal = async (modalProps: Partial = {}) => { formData={formData} onHideModal={() => setShowModal(false)} dataset={dataset} + drillByConfig={{ groupbyFieldName: 'groupby', filters: [] }} {...modalProps} /> )} @@ -104,7 +105,7 @@ beforeEach(() => { .post(CHART_DATA_ENDPOINT, { body: {} }, {}) .post(FORM_DATA_KEY_ENDPOINT, { key: '123' }); }); -afterEach(fetchMock.restore); +afterEach(() => fetchMock.restore()); test('should render the title', async () => { await renderModal(); @@ -127,16 +128,31 @@ test('should close the modal', async () => { }); test('should render loading indicator', async () => { - await renderModal(); - await waitFor(() => - expect(screen.getByLabelText('Loading')).toBeInTheDocument(), + fetchMock.post( + CHART_DATA_ENDPOINT, + { body: {} }, + // delay is missing in fetch-mock types + // @ts-ignore + { overwriteRoutes: true, delay: 1000 }, ); + await renderModal(); + expect(screen.getByLabelText('Loading')).toBeInTheDocument(); +}); + +test('should render alert banner when results fail to load', async () => { + await renderModal(); + expect( + await screen.findByText('There was an error loading the chart data'), + ).toBeInTheDocument(); }); test('should generate Explore url', async () => { await renderModal({ column: { column_name: 'name' }, - filters: [{ col: 'gender', op: '==', val: 'boy' }], + drillByConfig: { + filters: [{ col: 'gender', op: '==', val: 'boy' }], + groupbyFieldName: 'groupby', + }, }); await waitFor(() => fetchMock.called(CHART_DATA_ENDPOINT)); const expectedRequestPayload = { @@ -196,7 +212,10 @@ test('should render radio buttons', async () => { test('render breadcrumbs', async () => { await renderModal({ column: { column_name: 'name' }, - filters: [{ col: 'gender', op: '==', val: 'boy' }], + drillByConfig: { + filters: [{ col: 'gender', op: '==', val: 'boy' }], + groupbyFieldName: 'groupby', + }, }); const breadcrumbItems = screen.getAllByTestId('drill-by-breadcrumb-item'); diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx index 7f6557685487..df754f66a9bb 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx @@ -26,7 +26,6 @@ import React, { } from 'react'; import { BaseFormData, - BinaryQueryObjectFilterClause, Column, QueryData, css, @@ -34,6 +33,7 @@ import { isDefined, t, useTheme, + ContextMenuFilters, } from '@superset-ui/core'; import { useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; @@ -46,7 +46,8 @@ import { postFormData } from 'src/explore/exploreUtils/formData'; import { noOp } from 'src/utils/common'; import { simpleFilterToAdhoc } from 'src/utils/simpleFilterToAdhoc'; import { useDatasetMetadataBar } from 'src/features/datasets/metadataBar/useDatasetMetadataBar'; -import { SingleQueryResultPane } from 'src/explore/components/DataTablesPane/components/SingleQueryResultPane'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import Alert from 'src/components/Alert'; import { Dataset, DrillByType } from '../types'; import DrillByChart from './DrillByChart'; import { ContextMenuItem } from '../ChartContextMenu/ChartContextMenu'; @@ -57,14 +58,16 @@ import { DrillByBreadcrumb, useDrillByBreadcrumbs, } from './useDrillByBreadcrumbs'; +import { useResultsTableView } from './useResultsTableView'; -const DATA_SIZE = 15; +const DEFAULT_ADHOC_FILTER_FIELD_NAME = 'adhoc_filters'; interface ModalFooterProps { closeModal?: () => void; formData: BaseFormData; } const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => { + const { addDangerToast } = useToasts(); const [url, setUrl] = useState(''); const dashboardPageId = useContext(DashboardPageIdContext); const [datasource_id, datasource_type] = formData.datasource.split('__'); @@ -75,13 +78,24 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => { `/explore/?form_data_key=${key}&dashboard_page_id=${dashboardPageId}`, ); }) - .catch(e => { - console.log(e); + .catch(() => { + addDangerToast(t('Failed to generate chart edit URL')); }); - }, [dashboardPageId, datasource_id, datasource_type, formData]); + }, [ + addDangerToast, + dashboardPageId, + datasource_id, + datasource_type, + formData, + ]); return ( <> -