diff --git a/.github/workflows/bashlib.sh b/.github/workflows/bashlib.sh index 0d359e059e3..deb56ab649e 100644 --- a/.github/workflows/bashlib.sh +++ b/.github/workflows/bashlib.sh @@ -145,6 +145,7 @@ cypress-install() { cypress-run-all() { local USE_DASHBOARD=$1 + local APP_ROOT=$2 cd "$GITHUB_WORKSPACE/superset-frontend/cypress-base" # Start Flask and run it in background @@ -152,7 +153,12 @@ cypress-run-all() { # so errors can print to stderr. local flasklog="${HOME}/flask.log" local port=8081 - export CYPRESS_BASE_URL="http://localhost:${port}" + CYPRESS_BASE_URL="http://localhost:${port}" + if [ -n "$APP_ROOT" ]; then + export SUPERSET_APP_ROOT=$APP_ROOT + CYPRESS_BASE_URL=${CYPRESS_BASE_URL}${APP_ROOT} + fi + export CYPRESS_BASE_URL nohup flask run --no-debugger -p $port >"$flasklog" 2>&1 /dev/null 2>&1; then + uv pip install --no-cache-dir -r "${REQUIREMENTS_LOCAL}" + else + pip install --no-cache-dir -r "${REQUIREMENTS_LOCAL}" + fi else echo "Skipping local overrides" fi diff --git a/docker/docker-healthcheck.sh b/docker/docker-healthcheck.sh new file mode 100755 index 00000000000..36204fcef6b --- /dev/null +++ b/docker/docker-healthcheck.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# +# 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. +# + +curl -f "http://localhost:${SUPERSET_PORT}/${SUPERSET_APP_ROOT/\//}/health" || exit 1 diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index e60a07fbdf8..63d91ba051f 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -90,44 +90,5 @@ http { client_max_body_size 10m; - upstream superset_app { - server host.docker.internal:8088; - keepalive 100; - } - - upstream superset_websocket { - server host.docker.internal:8080; - keepalive 100; - } - - server { - listen 80 default_server; - server_name _; - - location /ws { - proxy_pass http://superset_websocket; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "Upgrade"; - proxy_set_header Host $host; - } - - location /static { - proxy_pass http://host.docker.internal:9000; # Proxy to superset-node - proxy_http_version 1.1; - proxy_set_header Host $host; - } - - location / { - proxy_pass http://superset_app; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $remote_addr; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_http_version 1.1; - port_in_redirect off; - proxy_connect_timeout 300; - } - } + include /etc/nginx/conf.d/superset.conf; } diff --git a/docker/nginx/templates/superset.conf.template b/docker/nginx/templates/superset.conf.template new file mode 100644 index 00000000000..e87eb85ef7a --- /dev/null +++ b/docker/nginx/templates/superset.conf.template @@ -0,0 +1,57 @@ +# 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. + +upstream superset_app { + server host.docker.internal:8088; + keepalive 100; +} + +upstream superset_websocket { + server host.docker.internal:8080; + keepalive 100; +} + +server { + listen 80 default_server; + server_name _; + + location /ws { + proxy_pass http://superset_websocket; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + } + + location ${SUPERSET_APP_ROOT}/static { + proxy_pass http://host.docker.internal:9000; # Proxy to superset-node + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location ${SUPERSET_APP_ROOT} { + proxy_pass http://superset_app; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + port_in_redirect off; + proxy_connect_timeout 300; + } + +} diff --git a/docker/pythonpath_dev/superset_config.py b/docker/pythonpath_dev/superset_config.py index cd5e7f39763..705846ac482 100644 --- a/docker/pythonpath_dev/superset_config.py +++ b/docker/pythonpath_dev/superset_config.py @@ -71,6 +71,7 @@ CACHE_CONFIG = { "CACHE_REDIS_DB": REDIS_RESULTS_DB, } DATA_CACHE_CONFIG = CACHE_CONFIG +THUMBNAIL_CACHE_CONFIG = CACHE_CONFIG class CeleryConfig: @@ -100,9 +101,11 @@ CELERY_CONFIG = CeleryConfig FEATURE_FLAGS = {"ALERT_REPORTS": True} ALERT_REPORTS_NOTIFICATION_DRY_RUN = True -WEBDRIVER_BASEURL = "http://superset:8088/" # When using docker compose baseurl should be http://superset_app:8088/ # noqa: E501 +WEBDRIVER_BASEURL = f"http://superset_app{os.environ.get('SUPERSET_APP_ROOT', '/')}/" # When using docker compose baseurl should be http://superset_nginx{ENV{BASEPATH}}/ # noqa: E501 # The base URL for the email report hyperlinks. -WEBDRIVER_BASEURL_USER_FRIENDLY = WEBDRIVER_BASEURL +WEBDRIVER_BASEURL_USER_FRIENDLY = ( + f"http://localhost:8888/{os.environ.get('SUPERSET_APP_ROOT', '/')}/" +) SQLLAB_CTAS_NO_LIMIT = True log_level_text = os.getenv("SUPERSET_LOG_LEVEL", "INFO") diff --git a/docs/docs/configuration/configuring-superset.mdx b/docs/docs/configuration/configuring-superset.mdx index c718880d808..0fb208d4f8c 100644 --- a/docs/docs/configuration/configuring-superset.mdx +++ b/docs/docs/configuration/configuring-superset.mdx @@ -215,6 +215,45 @@ In case the reverse proxy is used for providing SSL encryption, an explicit defi RequestHeader set X-Forwarded-Proto "https" ``` +## Configuring the application root + +*Please be advised that this feature is in BETA.* + +Superset supports running the application under a non-root path. The root path +prefix can be specified in one of two ways: + +- Setting the `SUPERSET_APP_ROOT` environment variable to the desired prefix. +- Customizing the [Flask entrypoint](https://github.com/apache/superset/blob/master/superset/app.py#L29) + by passing the `superset_app_root` variable. + +Note, the prefix should start with a `/`. + +### Customizing the Flask entrypoint + +To configure a prefix, e.g `/analytics`, pass the `superset_app_root` argument to +`create_app` when calling flask run either through the `FLASK_APP` +environment variable: + +```sh +FLASK_APP="superset:create_app(superset_app_root='/analytics')" +``` + +or as part of the `--app` argument to `flask run`: + +```sh +flask --app "superset.app:create_app(superset_app_root='/analytics')" +``` + +### Docker builds + +The [docker compose](/docs/installation/docker-compose#configuring-further) developer +configuration includes an additional environmental variable, +[`SUPERSET_APP_ROOT`](https://github.com/apache/superset/blob/master/docker/.env), +to simplify the process of setting up a non-default root path across the services. + +In `docker/.env-local` set `SUPERSET_APP_ROOT` to the desired prefix and then bring the +services up with `docker compose up --detach`. + ## Custom OAuth2 Configuration Superset is built on Flask-AppBuilder (FAB), which supports many providers out of the box diff --git a/docs/docs/configuration/networking-settings.mdx b/docs/docs/configuration/networking-settings.mdx index 4921a277d48..eced46d4873 100644 --- a/docs/docs/configuration/networking-settings.mdx +++ b/docs/docs/configuration/networking-settings.mdx @@ -138,4 +138,4 @@ of your additional middleware classes. For example, to use `AUTH_REMOTE_USER` from behind a proxy server like nginx, you have to add a simple middleware class to add the value of `HTTP_X_PROXY_REMOTE_USER` (or any other custom header -from the proxy) to Gunicorn’s `REMOTE_USER` environment variable: +from the proxy) to Gunicorn’s `REMOTE_USER` environment variable. diff --git a/docs/docs/quickstart.mdx b/docs/docs/quickstart.mdx index 9e5210bdfae..4ff3b381941 100644 --- a/docs/docs/quickstart.mdx +++ b/docs/docs/quickstart.mdx @@ -32,7 +32,7 @@ git clone https://github.com/apache/superset $ cd superset # Set the repo to the state associated with the latest official version -$ git checkout tags/4.1.1 +$ git checkout tags/4.1.2 # Fire up Superset using Docker Compose $ docker compose -f docker-compose-image-tag.yml up diff --git a/pyproject.toml b/pyproject.toml index 55364db11f2..7c1b0ca5fea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,7 +125,7 @@ denodo = ["denodo-sqlalchemy~=1.0.6"] dremio = ["sqlalchemy-dremio>=1.2.1, <4"] drill = ["sqlalchemy-drill>=1.1.4, <2"] druid = ["pydruid>=0.6.5,<0.7"] -duckdb = ["duckdb-engine>=0.10", "duckdb>=1.1.0"] +duckdb = ["duckdb-engine>=0.12.1, <0.13"] dynamodb = ["pydynamodb>=0.4.2"] solr = ["sqlalchemy-solr >= 0.2.0"] elasticsearch = ["elasticsearch-dbapi>=0.2.9, <0.3.0"] @@ -146,6 +146,7 @@ hive = [ impala = ["impyla>0.16.2, <0.17"] kusto = ["sqlalchemy-kusto>=3.0.0, <4"] kylin = ["kylinpy>=2.8.1, <2.9"] +motherduck = ["duckdb==0.10.2", "duckdb-engine>=0.12.1, <0.13"] mssql = ["pymssql>=2.2.8, <3"] mysql = ["mysqlclient>=2.1.0, <3"] ocient = [ diff --git a/scripts/change_detector.py b/scripts/change_detector.py index 7efd0ede5ec..06ed4647135 100755 --- a/scripts/change_detector.py +++ b/scripts/change_detector.py @@ -63,7 +63,10 @@ def fetch_files_github_api(url: str): # type: ignore def fetch_changed_files_pr(repo: str, pr_number: str) -> List[str]: """Fetches files changed in a PR using the GitHub API.""" - url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}/files" + + # NOTE: limited to 100 files ideally should page-through but instead resorting + # to assuming we should trigger when 100 files have been touched + url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}/files?per_page=100" files = fetch_files_github_api(url) return [file_info["filename"] for file_info in files] @@ -103,7 +106,7 @@ def main(event_type: str, sha: str, repo: str) -> None: """Main function to check for file changes based on event context.""" print("SHA:", sha) print("EVENT_TYPE", event_type) - files = None + files = [] if event_type == "pull_request": pr_number = os.getenv("GITHUB_REF", "").split("/")[-2] if is_int(pr_number): @@ -133,7 +136,10 @@ def main(event_type: str, sha: str, repo: str) -> None: output_path = os.getenv("GITHUB_OUTPUT") or "/tmp/GITHUB_OUTPUT.txt" # noqa: S108 with open(output_path, "a") as f: for check, changed in changes_detected.items(): - if changed: + # NOTE: as noted above, we assume that if 100 files are touched, we should + # trigger all checks. This is a workaround for the GitHub API limit of 100 + # files. Using >= 99 because off-by-one errors are not uncommon + if changed or len(files) >= 99: print(f"{check}={str(changed).lower()}", file=f) print(f"Triggering group: {check}") diff --git a/superset-frontend/cypress-base/cypress/applitools/explore.test.ts b/superset-frontend/cypress-base/cypress/applitools/explore.test.ts index 233701cbfff..9bdb1c390c9 100644 --- a/superset-frontend/cypress-base/cypress/applitools/explore.test.ts +++ b/superset-frontend/cypress-base/cypress/applitools/explore.test.ts @@ -23,7 +23,7 @@ import { describe('explore view', () => { beforeEach(() => { - cy.intercept('POST', '/superset/explore_json/**').as('getJson'); + cy.intercept('POST', '**/superset/explore_json/**').as('getJson'); }); afterEach(() => { diff --git a/superset-frontend/cypress-base/cypress/e2e/chart_list/list.test.ts b/superset-frontend/cypress-base/cypress/e2e/chart_list/list.test.ts index 50f14298674..7c3d924ee18 100644 --- a/superset-frontend/cypress-base/cypress/e2e/chart_list/list.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/chart_list/list.test.ts @@ -184,12 +184,13 @@ describe('Charts list', () => { }); it('should allow to favorite/unfavorite', () => { - cy.intercept({ url: `/api/v1/chart/*/favorites/`, method: 'POST' }).as( + cy.intercept({ url: `**/api/v1/chart/*/favorites/`, method: 'POST' }).as( 'select', ); - cy.intercept({ url: `/api/v1/chart/*/favorites/`, method: 'DELETE' }).as( - 'unselect', - ); + cy.intercept({ + url: `**/api/v1/chart/*/favorites/`, + method: 'DELETE', + }).as('unselect'); setGridMode('card'); orderAlphabetical(); diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/_skip.url_params.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/_skip.url_params.test.ts index 686c9e7536c..38241613e21 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard/_skip.url_params.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/_skip.url_params.test.ts @@ -27,13 +27,13 @@ describe.skip('Dashboard form data', () => { }); it('should apply url params to slice requests', () => { - cy.intercept('/api/v1/chart/data?*', request => { + cy.intercept('**/api/v1/chart/data?*', request => { // TODO: export url params to chart data API request.body.queries.forEach((query: { url_params: JsonObject }) => { expect(query.url_params).deep.eq(urlParams); }); }); - cy.intercept('/superset/explore_json/*', request => { + cy.intercept('**/superset/explore_json/*', request => { const requestParams = JSON.parse( parsePostForm(request.body).form_data as string, ); diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/drilltodetail.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/drilltodetail.test.ts index 4dc5ff9090d..0218496bba6 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard/drilltodetail.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/drilltodetail.test.ts @@ -25,7 +25,7 @@ import { } from './utils'; function interceptSamples() { - cy.intercept(`/datasource/samples*`).as('samples'); + cy.intercept(`**/datasource/samples*`).as('samples'); } function openModalFromMenu(chartType: string) { diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/horizontalFilterBar.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/horizontalFilterBar.test.ts index 020ecd35023..7cabc1a8099 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard/horizontalFilterBar.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/horizontalFilterBar.test.ts @@ -177,7 +177,7 @@ describe('Horizontal FilterBar', () => { }); it.skip('should spot changes in "more filters" and apply their values', () => { - cy.intercept(`/api/v1/chart/data?form_data=**`).as('chart'); + cy.intercept(`**/api/v1/chart/data?form_data=**`).as('chart'); prepareDashboardFilters([ { name: 'test_1', column: 'country_name', datasetId: 2 }, { name: 'test_2', column: 'country_code', datasetId: 2 }, diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/nativeFilters.noInitState.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/nativeFilters.noInitState.test.ts index 4777492a1c8..30936dcf365 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard/nativeFilters.noInitState.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/nativeFilters.noInitState.test.ts @@ -170,7 +170,7 @@ describe('Native filters', () => { testItems.datasetForNativeFilter, ); saveNativeFilterSettings(WORLD_HEALTH_CHARTS); - cy.intercept(`/api/v1/chart/data?form_data=**`).as('chart'); + cy.intercept(`**/api/v1/chart/data?form_data=**`).as('chart'); cy.get(nativeFilters.modal.container).should('not.exist'); // assert that native filter is created validateFilterNameOnDashboard(testItems.filterType.timeColumn); diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/shared_dashboard_functions.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/shared_dashboard_functions.ts index b0f7853e94b..595bf45da09 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard/shared_dashboard_functions.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/shared_dashboard_functions.ts @@ -55,6 +55,7 @@ export function prepareDashboardFilters( controlValues: { enableEmptyFilter: false, defaultToFirstItem: false, + creatable: true, multiSelect: true, searchAllOptions: false, inverseSelection: false, diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/tabs.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/tabs.test.ts index ee8814e71cf..0ae75c3201d 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard/tabs.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/tabs.test.ts @@ -116,7 +116,7 @@ describe('Dashboard tabs', () => { }); }); - cy.intercept('/superset/explore_json/?*').as('legacyChartData'); + cy.intercept('**/superset/explore_json/?*').as('legacyChartData'); // click row level tab, send 1 more query cy.get('.antd5-tabs-tab').contains('row tab 2').click(); @@ -131,7 +131,7 @@ describe('Dashboard tabs', () => { expect(requestParams.viz_type).eq(LINE_CHART.viz); }); - cy.intercept('POST', '/api/v1/chart/data?*').as('v1ChartData'); + cy.intercept('POST', '**/api/v1/chart/data?*').as('v1ChartData'); // click top level tab, send 1 more query cy.get('.antd5-tabs-tab').contains('Tab B').click(); diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/utils.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/utils.ts index 4dcaa3dac4b..d3da4bb4f85 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard/utils.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/utils.ts @@ -129,63 +129,63 @@ export const valueNativeFilterOptions = [ ]; export function interceptGet() { - cy.intercept('GET', '/api/v1/dashboard/*').as('get'); + cy.intercept('GET', '**/api/v1/dashboard/*').as('get'); } export function interceptFiltering() { - cy.intercept('GET', `/api/v1/dashboard/?q=*`).as('filtering'); + cy.intercept('GET', `**/api/v1/dashboard/?q=*`).as('filtering'); } export function interceptBulkDelete() { - cy.intercept('DELETE', `/api/v1/dashboard/?q=*`).as('bulkDelete'); + cy.intercept('DELETE', `**/api/v1/dashboard/?q=*`).as('bulkDelete'); } export function interceptDelete() { - cy.intercept('DELETE', `/api/v1/dashboard/*`).as('delete'); + cy.intercept('DELETE', `**/api/v1/dashboard/*`).as('delete'); } export function interceptUpdate() { - cy.intercept('PUT', `/api/v1/dashboard/*`).as('update'); + cy.intercept('PUT', `**/api/v1/dashboard/*`).as('update'); } export function interceptExploreUpdate() { - cy.intercept('PUT', `/api/v1/chart/*`).as('chartUpdate'); + cy.intercept('PUT', `**/api/v1/chart/*`).as('chartUpdate'); } export function interceptPost() { - cy.intercept('POST', `/api/v1/dashboard/`).as('post'); + cy.intercept('POST', `**/api/v1/dashboard/`).as('post'); } export function interceptLog() { - cy.intercept('/superset/log/?explode=events&dashboard_id=*').as('logs'); + cy.intercept('**/superset/log/?explode=events&dashboard_id=*').as('logs'); } export function interceptFav() { - cy.intercept({ url: `/api/v1/dashboard/*/favorites/`, method: 'POST' }).as( + cy.intercept({ url: `**/api/v1/dashboard/*/favorites/`, method: 'POST' }).as( 'select', ); } export function interceptUnfav() { - cy.intercept({ url: `/api/v1/dashboard/*/favorites/`, method: 'POST' }).as( + cy.intercept({ url: `**/api/v1/dashboard/*/favorites/`, method: 'POST' }).as( 'unselect', ); } export function interceptDataset() { - cy.intercept('GET', `/api/v1/dataset/*`).as('getDataset'); + cy.intercept('GET', `**/api/v1/dataset/*`).as('getDataset'); } export function interceptCharts() { - cy.intercept('GET', `/api/v1/dashboard/*/charts`).as('getCharts'); + cy.intercept('GET', `**/api/v1/dashboard/*/charts`).as('getCharts'); } export function interceptDatasets() { - cy.intercept('GET', `/api/v1/dashboard/*/datasets`).as('getDatasets'); + cy.intercept('GET', `**/api/v1/dashboard/*/datasets`).as('getDatasets'); } export function interceptFilterState() { - cy.intercept('POST', `/api/v1/dashboard/*/filter_state*`).as( + cy.intercept('POST', `**/api/v1/dashboard/*/filter_state*`).as( 'postFilterState', ); } diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/_skip.AdhocFilters.test.ts b/superset-frontend/cypress-base/cypress/e2e/explore/_skip.AdhocFilters.test.ts index 2773641b10e..339a01f24cf 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/_skip.AdhocFilters.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/explore/_skip.AdhocFilters.test.ts @@ -18,11 +18,11 @@ */ describe.skip('AdhocFilters', () => { beforeEach(() => { - cy.intercept('GET', '/api/v1/datasource/table/*/column/name/values').as( + cy.intercept('GET', '**/api/v1/datasource/table/*/column/name/values').as( 'filterValues', ); - cy.intercept('POST', '/superset/explore_json/**').as('postJson'); - cy.intercept('GET', '/superset/explore_json/**').as('getJson'); + cy.intercept('POST', '**/superset/explore_json/**').as('postJson'); + cy.intercept('GET', '**/superset/explore_json/**').as('getJson'); cy.visitChartByName('Boys'); // a table chart cy.verifySliceSuccess({ waitAlias: '@postJson' }); }); diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/advanced_analytics.test.ts b/superset-frontend/cypress-base/cypress/e2e/explore/advanced_analytics.test.ts index 6d59c15bcb7..3cfaea9fb7e 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/advanced_analytics.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/explore/advanced_analytics.test.ts @@ -21,8 +21,8 @@ import { interceptV1ChartData } from './utils'; describe('Advanced analytics', () => { beforeEach(() => { interceptV1ChartData(); - cy.intercept('PUT', '/api/v1/explore/**').as('putExplore'); - cy.intercept('GET', '/explore/**').as('getExplore'); + cy.intercept('PUT', '**/api/v1/explore/**').as('putExplore'); + cy.intercept('GET', '**/explore/**').as('getExplore'); }); it('Create custom time compare', () => { diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/control.test.ts b/superset-frontend/cypress-base/cypress/e2e/explore/control.test.ts index a796a2e11b7..2a0d4682924 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/control.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/explore/control.test.ts @@ -158,7 +158,7 @@ describe('Test datatable', () => { }); it('Datapane loads view samples', () => { cy.intercept( - 'datasource/samples?force=false&datasource_type=table&datasource_id=*', + '**/datasource/samples?force=false&datasource_type=table&datasource_id=*', ).as('Samples'); cy.contains('Samples').click(); cy.wait('@Samples'); diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/utils.ts b/superset-frontend/cypress-base/cypress/e2e/explore/utils.ts index ee28151c156..994f260e706 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/utils.ts +++ b/superset-frontend/cypress-base/cypress/e2e/explore/utils.ts @@ -20,41 +20,41 @@ import { interceptGet as interceptDashboardGet } from '../dashboard/utils'; export function interceptFiltering() { - cy.intercept('GET', `/api/v1/chart/?q=*`).as('filtering'); + cy.intercept('GET', `**/api/v1/chart/?q=*`).as('filtering'); } export function interceptBulkDelete() { - cy.intercept('DELETE', `/api/v1/chart/?q=*`).as('bulkDelete'); + cy.intercept('DELETE', `**/api/v1/chart/?q=*`).as('bulkDelete'); } export function interceptDelete() { - cy.intercept('DELETE', `/api/v1/chart/*`).as('delete'); + cy.intercept('DELETE', `**/api/v1/chart/*`).as('delete'); } export function interceptFavoriteStatus() { - cy.intercept('GET', '/api/v1/chart/favorite_status/*').as('favoriteStatus'); + cy.intercept('GET', '**/api/v1/chart/favorite_status/*').as('favoriteStatus'); } export function interceptUpdate() { - cy.intercept('PUT', `/api/v1/chart/*`).as('update'); + cy.intercept('PUT', `**/api/v1/chart/*`).as('update'); } export const interceptV1ChartData = (alias = 'v1Data') => { - cy.intercept('/api/v1/chart/data*').as(alias); + cy.intercept('**/api/v1/chart/data*').as(alias); }; export function interceptExploreJson(alias = 'getJson') { - cy.intercept('POST', `/superset/explore_json/**`).as(alias); + cy.intercept('POST', `**/superset/explore_json/**`).as(alias); } export const interceptFormDataKey = () => { - cy.intercept('POST', '/api/v1/explore/form_data').as('formDataKey'); + cy.intercept('POST', '**/api/v1/explore/form_data').as('formDataKey'); }; export function interceptExploreGet() { cy.intercept({ method: 'GET', - url: /api\/v1\/explore\/\?(form_data_key|dashboard_page_id|slice_id)=.*/, + url: /.*\/api\/v1\/explore\/\?(form_data_key|dashboard_page_id|slice_id)=.*/, }).as('getExplore'); } @@ -109,5 +109,5 @@ export function saveChartToDashboard(chartName: string, dashboardName: string) { export function visitSampleChartFromList(chartName: string) { cy.getBySel('table-row').contains(chartName).click(); - cy.intercept('POST', '/superset/explore_json/**').as('getJson'); + cy.intercept('POST', '**/superset/explore_json/**').as('getJson'); } diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/box_plot.test.js b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/box_plot.test.js index 37525d9ac88..2758652f342 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/box_plot.test.js +++ b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/box_plot.test.js @@ -18,7 +18,7 @@ */ describe('Visualization > Box Plot', () => { beforeEach(() => { - cy.intercept('POST', '/api/v1/chart/data*').as('getJson'); + cy.intercept('POST', '**/api/v1/chart/data*').as('getJson'); }); const BOX_PLOT_FORM_DATA = { diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/bubble.test.js b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/bubble.test.js index 29e0508069f..90c5b10ffad 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/bubble.test.js +++ b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/bubble.test.js @@ -18,7 +18,7 @@ */ describe('Visualization > Bubble', () => { beforeEach(() => { - cy.intercept('POST', '/superset/explore_json/**').as('getJson'); + cy.intercept('POST', '**/superset/explore_json/**').as('getJson'); }); const BUBBLE_FORM_DATA = { diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/compare.test.js b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/compare.test.js index ba699cf0a83..8b699bbedc1 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/compare.test.js +++ b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/compare.test.js @@ -18,7 +18,7 @@ */ describe('Visualization > Compare', () => { beforeEach(() => { - cy.intercept('POST', '/superset/explore_json/**').as('getJson'); + cy.intercept('POST', '**/superset/explore_json/**').as('getJson'); }); const COMPARE_FORM_DATA = { diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/download_chart.test.js b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/download_chart.test.js index 76653dada9c..804f35ca7b0 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/download_chart.test.js +++ b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/download_chart.test.js @@ -25,7 +25,7 @@ describe('Download Chart > Bar chart', () => { }; beforeEach(() => { - cy.intercept('POST', '/superset/explore_json/**').as('getJson'); + cy.intercept('POST', '**/superset/explore_json/**').as('getJson'); }); it('download chart with image works', () => { diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/gauge.test.js b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/gauge.test.js index f67daa65acf..4ced9b726b2 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/gauge.test.js +++ b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/gauge.test.js @@ -19,7 +19,7 @@ describe('Visualization > Gauge', () => { beforeEach(() => { - cy.intercept('POST', '/api/v1/chart/data*').as('getJson'); + cy.intercept('POST', '**/api/v1/chart/data*').as('getJson'); }); const GAUGE_FORM_DATA = { diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/graph.test.ts b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/graph.test.ts index e7ee7204083..05140ec69fa 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/graph.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/graph.test.ts @@ -28,7 +28,7 @@ type adhocFilter = { describe('Visualization > Graph', () => { beforeEach(() => { - cy.intercept('POST', '/api/v1/chart/data*').as('getJson'); + cy.intercept('POST', '**/api/v1/chart/data*').as('getJson'); }); const GRAPH_FORM_DATA = { diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/pie.test.js b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/pie.test.js index 87c411f2004..0fc03ccb13b 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/pie.test.js +++ b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/pie.test.js @@ -18,7 +18,7 @@ */ describe('Visualization > Pie', () => { beforeEach(() => { - cy.intercept('POST', '/api/v1/chart/data*').as('getJson'); + cy.intercept('POST', '**/api/v1/chart/data*').as('getJson'); }); const PIE_FORM_DATA = { diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/pivot_table.test.js b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/pivot_table.test.js index e1a4c7b9c81..aba28794334 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/pivot_table.test.js +++ b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/pivot_table.test.js @@ -18,7 +18,7 @@ */ describe('Visualization > Pivot Table', () => { beforeEach(() => { - cy.intercept('POST', '/api/v1/chart/data**').as('chartData'); + cy.intercept('POST', '**/api/v1/chart/data**').as('chartData'); }); const PIVOT_TABLE_FORM_DATA = { diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/sunburst.test.js b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/sunburst.test.js index 28b6c14122e..9686f18f0be 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/sunburst.test.js +++ b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/sunburst.test.js @@ -18,7 +18,7 @@ */ describe('Visualization > Sunburst', () => { beforeEach(() => { - cy.intercept('POST', '/api/v1/chart/data**').as('chartData'); + cy.intercept('POST', '**/api/v1/chart/data**').as('chartData'); }); const SUNBURST_FORM_DATA = { diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/time_table.js b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/time_table.js index 5c8672192a8..362f3daabbf 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/time_table.js +++ b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/time_table.js @@ -20,7 +20,7 @@ import { FORM_DATA_DEFAULTS, NUM_METRIC } from './shared.helper'; describe('Visualization > Time TableViz', () => { beforeEach(() => { - cy.intercept('POST', '/superset/explore_json/**').as('getJson'); + cy.intercept('POST', '**/superset/explore_json/**').as('getJson'); }); const VIZ_DEFAULTS = { ...FORM_DATA_DEFAULTS, viz_type: 'time_table' }; diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/world_map.test.js b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/world_map.test.js index 12ccb3f5f06..12f099bba65 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/world_map.test.js +++ b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/world_map.test.js @@ -18,7 +18,7 @@ */ describe('Visualization > World Map', () => { beforeEach(() => { - cy.intercept('POST', '/superset/explore_json/**').as('getJson'); + cy.intercept('POST', '**/superset/explore_json/**').as('getJson'); }); const WORLD_MAP_FORM_DATA = { diff --git a/superset-frontend/cypress-base/cypress/e2e/sqllab/_skip.sourcePanel.index.test.js b/superset-frontend/cypress-base/cypress/e2e/sqllab/_skip.sourcePanel.index.test.js index e746489ce12..4b24c844364 100644 --- a/superset-frontend/cypress-base/cypress/e2e/sqllab/_skip.sourcePanel.index.test.js +++ b/superset-frontend/cypress-base/cypress/e2e/sqllab/_skip.sourcePanel.index.test.js @@ -26,7 +26,7 @@ describe.skip('SqlLab datasource panel', () => { // TODO the test below is flaky, and has been disabled for the time being // (notice the `it.skip`) it('creates a table preview when a database, schema, and table are selected', () => { - cy.intercept('/superset/table/**').as('tableMetadata'); + cy.intercept('**/superset/table/**').as('tableMetadata'); // it should have dropdowns to select database, schema, and table cy.get('.sql-toolbar .Select').should('have.length', 3); diff --git a/superset-frontend/cypress-base/cypress/e2e/sqllab/query.test.ts b/superset-frontend/cypress-base/cypress/e2e/sqllab/query.test.ts index 3269b48a9a2..53f710b3a9d 100644 --- a/superset-frontend/cypress-base/cypress/e2e/sqllab/query.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/sqllab/query.test.ts @@ -35,7 +35,7 @@ describe('SqlLab query panel', () => { cy.intercept({ method: 'POST', - url: '/api/v1/sqllab/execute/', + url: '**/api/v1/sqllab/execute/', }).as('mockSQLResponse'); cy.get('.TableSelector .Select:eq(0)').click(); @@ -79,7 +79,7 @@ describe('SqlLab query panel', () => { }); it.skip('successfully saves a query', () => { - cy.intercept('api/v1/database/**/tables/**').as('getTables'); + cy.intercept('**/api/v1/database/**/tables/**').as('getTables'); const query = 'SELECT ds, gender, name, num FROM main.birth_names ORDER BY name LIMIT 3'; @@ -142,7 +142,7 @@ describe('SqlLab query panel', () => { }); it.skip('Create a chart from a query', () => { - cy.intercept('/api/v1/sqllab/execute/').as('queryFinished'); + cy.intercept('**/api/v1/sqllab/execute/').as('queryFinished'); cy.intercept('**/api/v1/explore/**').as('explore'); cy.intercept('**/api/v1/chart/**').as('chart'); cy.intercept('**/tabstateview/**').as('tabstateview'); diff --git a/superset-frontend/cypress-base/cypress/utils/vizPlugins.ts b/superset-frontend/cypress-base/cypress/utils/vizPlugins.ts index c67da1afd5f..af06ad02993 100644 --- a/superset-frontend/cypress-base/cypress/utils/vizPlugins.ts +++ b/superset-frontend/cypress-base/cypress/utils/vizPlugins.ts @@ -65,9 +65,9 @@ export function getChartDataRouteForSlice(slice: Slice) { const isLegacy = isLegacyChart(vizType); const formData = encodeURIComponent(`{"slice_id":${slice.slice_id}}`); if (isLegacy) { - return `/superset/explore_json/?*${formData}*`; + return `**/superset/explore_json/?*${formData}*`; } - return `/api/v1/chart/data?*${formData}*`; + return `**/api/v1/chart/data?*${formData}*`; } export function getChartAlias(slice: Slice): string { diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index d966d9724d5..72a78ff2797 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -101,7 +101,6 @@ "mousetrap": "^1.6.5", "mustache": "^4.2.0", "nanoid": "^5.0.9", - "nwsapi": "^2.2.13", "ol": "^7.5.2", "polished": "^4.3.1", "prop-types": "^15.8.1", @@ -276,7 +275,7 @@ "jest-html-reporter": "^3.10.2", "jest-websocket-mock": "^2.5.0", "jsdom": "^26.0.0", - "lerna": "^8.1.7", + "lerna": "^8.2.1", "less": "^4.2.0", "less-loader": "^12.2.0", "mini-css-extract-plugin": "^2.9.0", @@ -3774,9 +3773,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.3.1.tgz", - "integrity": "sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.0.tgz", + "integrity": "sha512-H+N/FqT07NmLmt6OFFtDfwe8PNygprzBikrEMyQfgqSmT0vzE515Pz7R8izwB9q/zsH/MA64AKoul3sA6/CzVg==", "dev": true, "license": "MIT", "dependencies": { @@ -3785,9 +3784,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", - "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.0.tgz", + "integrity": "sha512-64WYIf4UYcdLnbKn/umDlNjQDSS8AgZrI/R9+x5ilkUVFxXcA1Ebl+gQLc/6mERA4407Xof0R7wEyEuj091CVw==", "dev": true, "license": "MIT", "dependencies": { @@ -5642,9 +5641,9 @@ "license": "MIT" }, "node_modules/@lerna/create": { - "version": "8.1.9", - "resolved": "https://registry.npmjs.org/@lerna/create/-/create-8.1.9.tgz", - "integrity": "sha512-DPnl5lPX4v49eVxEbJnAizrpMdMTBz1qykZrAbBul9rfgk531v8oAt+Pm6O/rpAleRombNM7FJb5rYGzBJatOQ==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@lerna/create/-/create-8.2.1.tgz", + "integrity": "sha512-Cz2u/fwc03D1EE6VFZCLMmI8FIUtGmxHQ3ECeNblsxv9i0YSKWe4Xm18sjO1xltG/K5ByiH8/HMeY9dlyAv22A==", "dev": true, "license": "MIT", "dependencies": { @@ -5653,7 +5652,7 @@ "@npmcli/run-script": "8.1.0", "@nx/devkit": ">=17.1.2 < 21", "@octokit/plugin-enterprise-rest": "6.0.1", - "@octokit/rest": "19.0.11", + "@octokit/rest": "20.1.2", "aproba": "2.0.0", "byte-size": "8.1.1", "chalk": "4.1.0", @@ -5706,7 +5705,6 @@ "slash": "^3.0.0", "ssri": "^10.0.6", "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", "strong-log-transformer": "2.1.0", "tar": "6.2.1", "temp-dir": "1.0.0", @@ -6158,19 +6156,6 @@ "node": ">=8" } }, - "node_modules/@lerna/create/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@lerna/create/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -7180,9 +7165,9 @@ } }, "node_modules/@nx/devkit": { - "version": "20.3.2", - "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-20.3.2.tgz", - "integrity": "sha512-VhbxEsSTCZlOVgjuQC+6HQmb9Oz9VoHUeo4001Pw6BFBcSXZUi5q37C/lxbAgQPnMKLkFcLva3WKZ+fOLwhGIg==", + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-20.7.0.tgz", + "integrity": "sha512-BzgeF7sVM6eLVVZIkhqwkSu8hzKx+Wa/bLyMithiU+aLJVGALWFQriDS68MBMA+MHyodKwV3QHQH9/9/UO6uyg==", "dev": true, "license": "MIT", "dependencies": { @@ -7239,9 +7224,9 @@ } }, "node_modules/@nx/nx-darwin-arm64": { - "version": "20.3.2", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-20.3.2.tgz", - "integrity": "sha512-lQOXMIPmE9o36TuZ+SX6iq7PPWa3s1fjNRqCujlviExX69245NNCMxd754gXlLrsxC1onrx/zmJciKmmEWDIiw==", + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-20.7.0.tgz", + "integrity": "sha512-SuPP4AlBGuQjfkgFaGAO66GEUll0Isir5HKa0w0LcehHmxncE9LT4YagEJONPDp6SVDIjpoFIRw71jf9toFvPQ==", "cpu": [ "arm64" ], @@ -7256,9 +7241,9 @@ } }, "node_modules/@nx/nx-darwin-x64": { - "version": "20.3.2", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-20.3.2.tgz", - "integrity": "sha512-RvvSz4QYVOYOfC8sUE63b6dy8iHk2AEI0r1FF5FCQuqE1DdTeTjPETY2sY35tRqF+mO/6oLGp2+m9ti/ysRoTg==", + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-20.7.0.tgz", + "integrity": "sha512-eJnGtZmXzhRqitBBSIU7CjUjEGyo7QMZlvTQsqIpkZDay3v3JyQ9tikdGe3L6BwnTgeFQsBxNfK3ddmwTyK19g==", "cpu": [ "x64" ], @@ -7273,9 +7258,9 @@ } }, "node_modules/@nx/nx-freebsd-x64": { - "version": "20.3.2", - "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-20.3.2.tgz", - "integrity": "sha512-KBDTyGn1evlZ17pupwRUDh2wrCMuHhP2j8cOCdgF5cl7vRki8BOK9yyL6jD11d/d/6DgXzy1jmQEX4Xx+AGCug==", + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-20.7.0.tgz", + "integrity": "sha512-WhuK5XGy2c7WNMrVW2+mWMb0MCvgoOaSUxvxpl8OORdENYmgsUjYUDVnndjqRdSzcQTB/WfQX5G7bJQhySzaww==", "cpu": [ "x64" ], @@ -7290,9 +7275,9 @@ } }, "node_modules/@nx/nx-linux-arm-gnueabihf": { - "version": "20.3.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-20.3.2.tgz", - "integrity": "sha512-mW+OcOnJEMvs7zD3aSwEG3z5M9bI4CuUU5Q/ePmnNzWIucRHpoAMNt/Sd+yu6L4+QttvoUf967uwcMsX8l4nrw==", + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-20.7.0.tgz", + "integrity": "sha512-TZKDjO/YyOT/Zq449kIpkw9OUD4EQlmMk+aM8WW29VirJnnIfGxhjfCBkSRmVIOvwSlIf7GKLQjDuWwcObIZRg==", "cpu": [ "arm" ], @@ -7307,9 +7292,9 @@ } }, "node_modules/@nx/nx-linux-arm64-gnu": { - "version": "20.3.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-20.3.2.tgz", - "integrity": "sha512-hbXpZqUvGY5aeEWvh0SNsiYjP1ytSM30XOT6qN6faLO2CL/7j9D2UB69SKOqF3TJOvuNU6cweFgZCxyGfXBYIQ==", + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-20.7.0.tgz", + "integrity": "sha512-wEkE/MRcPQHZK10SgocPGCEyqFksLDdsSeE2fyVZb+N+vUGSp5YBu7OE6mUKobIs3uUXgftqP4d7QXTlehb7gw==", "cpu": [ "arm64" ], @@ -7324,9 +7309,9 @@ } }, "node_modules/@nx/nx-linux-arm64-musl": { - "version": "20.3.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-20.3.2.tgz", - "integrity": "sha512-HXthtN7adXCNVWs2F4wIqq2f7BcKTjsEnqg2LWV5lm4hRYvMfEvPftb0tECsEhcSQQYcvIJnLfv3vtu9HZSfVA==", + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-20.7.0.tgz", + "integrity": "sha512-je7OmX7d41ihMzq4/q6AOsYGfiH5B3xIsAKBfXHSmlGcaijnxMCzSHzjR3znxhmOS0bMcfICXCCwm1AVcyUAUA==", "cpu": [ "arm64" ], @@ -7341,9 +7326,9 @@ } }, "node_modules/@nx/nx-linux-x64-gnu": { - "version": "20.3.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-20.3.2.tgz", - "integrity": "sha512-HhgHqOUT05H45zuQL+XPywQbRNFttd7Rkkr7dZnpCRdp4W8GDjfyKCoCS5qVyowAyNh9Vc7VEq9qmiLMlvf6Zg==", + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-20.7.0.tgz", + "integrity": "sha512-dF5/VJgtbydSgu9WjL1CrzNIXZR/9Z92b4f7lrvd/KO/LwBB3EVavgV0tSq0TAFuu4n5eSo66SA1j0miIBA1dg==", "cpu": [ "x64" ], @@ -7358,9 +7343,9 @@ } }, "node_modules/@nx/nx-linux-x64-musl": { - "version": "20.3.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-20.3.2.tgz", - "integrity": "sha512-NrZ8L9of2GmYEM8GMJX6QRrLJlAwM+ds2rhdY1bxwpiyCNcD3IO/gzJlBs+kG4ly05F1u/X4k/FI5dXPpjUSgw==", + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-20.7.0.tgz", + "integrity": "sha512-Jy760sdTDgzplpWerrUpfugmHnjQoNbCdcrNiBm1HaL2+P/6Wl2eZ2zLW3Uw5/D/wf7z8qsokMrPO9XdvnZ3cA==", "cpu": [ "x64" ], @@ -7375,9 +7360,9 @@ } }, "node_modules/@nx/nx-win32-arm64-msvc": { - "version": "20.3.2", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-20.3.2.tgz", - "integrity": "sha512-yLjacZND7C1XmsC0jfRLSgeLWZUw2Oz+u3nXNvj5JX6YHtYTVLFnRbTAcI+pG2Y6v0Otf2GKb3VT5d1mQb8JvA==", + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-20.7.0.tgz", + "integrity": "sha512-5UvJg1RfOeSwcMr/7ghQA5yH3kpU8wye81sCLtyKPfBeH312Ntm52+ckc1IiCFAIkVyo2rmWF9ogRzOTR1O9sw==", "cpu": [ "arm64" ], @@ -7392,9 +7377,9 @@ } }, "node_modules/@nx/nx-win32-x64-msvc": { - "version": "20.3.2", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-20.3.2.tgz", - "integrity": "sha512-oDhcctfk0UB1V+Otp1161VKNMobzkFQxGyiEIjp0CjCBa2eRHC1r35L695F1Hj0bvLQPSni9XIe9evh2taeAkg==", + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-20.7.0.tgz", + "integrity": "sha512-Ddtk/owLLj1wRHB5MUuUjEfxn9h6sQntOpCC1qDYJLCLRGe3jCEx7MHxKWo5MlmtShXeHRrS27CrA5AGzSS+6w==", "cpu": [ "x64" ], @@ -7575,19 +7560,170 @@ } }, "node_modules/@octokit/rest": { - "version": "19.0.11", - "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-19.0.11.tgz", - "integrity": "sha512-m2a9VhaP5/tUw8FwfnW2ICXlXpLPIqxtg3XcAiGMLj/Xhw3RSBfZ8le/466ktO1Gcjr8oXudGnHhxV1TXJgFxw==", + "version": "20.1.2", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-20.1.2.tgz", + "integrity": "sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/core": "^4.2.1", - "@octokit/plugin-paginate-rest": "^6.1.2", - "@octokit/plugin-request-log": "^1.0.4", - "@octokit/plugin-rest-endpoint-methods": "^7.1.2" + "@octokit/core": "^5.0.2", + "@octokit/plugin-paginate-rest": "11.4.4-cjs.2", + "@octokit/plugin-request-log": "^4.0.0", + "@octokit/plugin-rest-endpoint-methods": "13.3.2-cjs.1" }, "engines": { - "node": ">= 14" + "node": ">= 18" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/auth-token": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", + "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/core": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.1.tgz", + "integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.4.1", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/endpoint": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.6.tgz", + "integrity": "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/graphql": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", + "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^8.4.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/rest/node_modules/@octokit/plugin-paginate-rest": { + "version": "11.4.4-cjs.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.4.4-cjs.2.tgz", + "integrity": "sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.7.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/plugin-request-log": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-4.0.1.tgz", + "integrity": "sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "13.3.2-cjs.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.3.2-cjs.1.tgz", + "integrity": "sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.8.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "^5" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/request": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.1.tgz", + "integrity": "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^9.0.6", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/request-error": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.1.tgz", + "integrity": "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" } }, "node_modules/@octokit/tsconfig": { @@ -16027,9 +16163,9 @@ } }, "node_modules/axios": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", - "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", + "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", "dev": true, "license": "MIT", "dependencies": { @@ -23632,9 +23768,9 @@ } }, "node_modules/exponential-backoff": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", - "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", + "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", "dev": true, "license": "Apache-2.0" }, @@ -29051,9 +29187,9 @@ } }, "node_modules/is-ssh": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.4.0.tgz", - "integrity": "sha512-x7+VxdxOdlV3CYpjvRLBv5Lo9OJerlYanjwFrPR9fuGPjCiNiCzFgAWpiLAohSbsnH4ZAys3SBh+hq5rJosxUQ==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.4.1.tgz", + "integrity": "sha512-JNeu1wQsHjyHgn9NcWTaXq6zWSR6hqE0++zhfZlkFBbScNkyvxCdeV8sRkSBaeLKxmbpR21brail63ACNxJ0Tg==", "dev": true, "license": "MIT", "dependencies": { @@ -32887,19 +33023,19 @@ "license": "Apache-2.0" }, "node_modules/lerna": { - "version": "8.1.9", - "resolved": "https://registry.npmjs.org/lerna/-/lerna-8.1.9.tgz", - "integrity": "sha512-ZRFlRUBB2obm+GkbTR7EbgTMuAdni6iwtTQTMy7LIrQ4UInG44LyfRepljtgUxh4HA0ltzsvWfPkd5J1DKGCeQ==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/lerna/-/lerna-8.2.1.tgz", + "integrity": "sha512-Xwjv9/4ixp7fpBWhtvp7dz4NoQT8DEf7hzibHKCgu/8kmZUHeXsTn+TKspHqhI+p4YDmdkDnkg8xmymz73kVOg==", "dev": true, "license": "MIT", "dependencies": { - "@lerna/create": "8.1.9", + "@lerna/create": "8.2.1", "@npmcli/arborist": "7.5.4", "@npmcli/package-json": "5.2.0", "@npmcli/run-script": "8.1.0", "@nx/devkit": ">=17.1.2 < 21", "@octokit/plugin-enterprise-rest": "6.0.1", - "@octokit/rest": "19.0.11", + "@octokit/rest": "20.1.2", "aproba": "2.0.0", "byte-size": "8.1.1", "chalk": "4.1.0", @@ -32960,7 +33096,6 @@ "slash": "3.0.0", "ssri": "^10.0.6", "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", "strong-log-transformer": "2.1.0", "tar": "6.2.1", "temp-dir": "1.0.0", @@ -37057,12 +37192,13 @@ "version": "2.2.13", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.13.tgz", "integrity": "sha512-cTGB9ptp9dY9A5VbMSe7fQBcl/tt22Vcqdq8+eN93rblOuE0aCFu4aZ2vMwct/2t+lFnosm8RkQW1I0Omb1UtQ==", + "dev": true, "license": "MIT" }, "node_modules/nx": { - "version": "20.3.2", - "resolved": "https://registry.npmjs.org/nx/-/nx-20.3.2.tgz", - "integrity": "sha512-VWUHX0uCn8ACFbpBTpgucDzwe4q/a/UU3AYOhzKCvTzb3kQiyvoxLjORSze93ZNEqgor0PMkCQgcoMBUjxJfzQ==", + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/nx/-/nx-20.7.0.tgz", + "integrity": "sha512-IcrQr6alrSJTl5pb80Y/ytwK5Bsx7zC0LbJj5Ck5K+dctFKO2sEAvB2hKz5GiZx92NJzcfCVc5Lu44UeZST1bw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -37071,7 +37207,7 @@ "@yarnpkg/lockfile": "^1.1.0", "@yarnpkg/parsers": "3.0.2", "@zkochan/js-yaml": "0.0.7", - "axios": "^1.7.4", + "axios": "^1.8.3", "chalk": "^4.1.0", "cli-cursor": "3.1.0", "cli-spinners": "2.6.1", @@ -37107,16 +37243,16 @@ "nx-cloud": "bin/nx-cloud.js" }, "optionalDependencies": { - "@nx/nx-darwin-arm64": "20.3.2", - "@nx/nx-darwin-x64": "20.3.2", - "@nx/nx-freebsd-x64": "20.3.2", - "@nx/nx-linux-arm-gnueabihf": "20.3.2", - "@nx/nx-linux-arm64-gnu": "20.3.2", - "@nx/nx-linux-arm64-musl": "20.3.2", - "@nx/nx-linux-x64-gnu": "20.3.2", - "@nx/nx-linux-x64-musl": "20.3.2", - "@nx/nx-win32-arm64-msvc": "20.3.2", - "@nx/nx-win32-x64-msvc": "20.3.2" + "@nx/nx-darwin-arm64": "20.7.0", + "@nx/nx-darwin-x64": "20.7.0", + "@nx/nx-freebsd-x64": "20.7.0", + "@nx/nx-linux-arm-gnueabihf": "20.7.0", + "@nx/nx-linux-arm64-gnu": "20.7.0", + "@nx/nx-linux-arm64-musl": "20.7.0", + "@nx/nx-linux-x64-gnu": "20.7.0", + "@nx/nx-linux-x64-musl": "20.7.0", + "@nx/nx-win32-arm64-msvc": "20.7.0", + "@nx/nx-win32-x64-msvc": "20.7.0" }, "peerDependencies": { "@swc-node/register": "^1.8.0", @@ -38926,9 +39062,9 @@ } }, "node_modules/parse-path": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse-path/-/parse-path-7.0.0.tgz", - "integrity": "sha512-Euf9GG8WT9CdqwuWJGdf3RkUcTBArppHABkO7Lm8IzRQp0e2r/kkFnmhu4TSK30Wcu5rVAZLmfPKSBBi9tWFog==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/parse-path/-/parse-path-7.0.1.tgz", + "integrity": "sha512-6ReLMptznuuOEzLoGEa+I1oWRSj2Zna5jLWC+l6zlfAI4dbbSaIES29ThzuPkbhNahT65dWzfoZEO6cfJw2Ksg==", "dev": true, "license": "MIT", "dependencies": { @@ -40344,9 +40480,9 @@ "license": "MIT" }, "node_modules/protocols": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.1.tgz", - "integrity": "sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.2.tgz", + "integrity": "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==", "dev": true, "license": "MIT" }, @@ -51947,9 +52083,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", "dev": true, "license": "ISC", "bin": { diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 82501d8e958..6823625686c 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -343,7 +343,7 @@ "jest-html-reporter": "^3.10.2", "jest-websocket-mock": "^2.5.0", "jsdom": "^26.0.0", - "lerna": "^8.1.7", + "lerna": "^8.2.1", "less": "^4.2.0", "less-loader": "^12.2.0", "mini-css-extract-plugin": "^2.9.0", diff --git a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts index fd040faed04..92f2e50198f 100644 --- a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts +++ b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts @@ -31,11 +31,11 @@ import { RequestConfig, ParseMethod, } from './types'; -import { DEFAULT_FETCH_RETRY_OPTIONS, DEFAULT_BASE_URL } from './constants'; +import { DEFAULT_FETCH_RETRY_OPTIONS, DEFAULT_APP_ROOT } from './constants'; -const defaultUnauthorizedHandler = () => { - if (!window.location.pathname.startsWith('/login')) { - window.location.href = `/login?next=${window.location.href}`; +const defaultUnauthorizedHandlerForPrefix = (appRoot: string) => () => { + if (!window.location.pathname.startsWith(`${appRoot}/login`)) { + window.location.href = `${appRoot}/login?next=${window.location.href}`; } }; @@ -52,7 +52,7 @@ export default class SupersetClientClass { fetchRetryOptions?: FetchRetryOptions; - baseUrl: string; + appRoot?: string; protocol: Protocol; @@ -67,9 +67,9 @@ export default class SupersetClientClass { handleUnauthorized: () => void; constructor({ - baseUrl = DEFAULT_BASE_URL, host, protocol, + appRoot = DEFAULT_APP_ROOT, headers = {}, fetchRetryOptions = {}, mode = 'same-origin', @@ -78,17 +78,10 @@ export default class SupersetClientClass { csrfToken = undefined, guestToken = undefined, guestTokenHeaderName = 'X-GuestToken', - unauthorizedHandler = defaultUnauthorizedHandler, + unauthorizedHandler = undefined, }: ClientConfig = {}) { - const url = new URL( - host || protocol - ? `${protocol || 'https:'}//${host || 'localhost'}` - : baseUrl, - // baseUrl for API could also be relative, so we provide current location.href - // as the base of baseUrl - window.location.href, - ); - this.baseUrl = url.href.replace(/\/+$/, ''); // always strip trailing slash + const url = new URL(`${protocol || 'https:'}//${host || 'localhost'}`); + this.appRoot = appRoot; this.host = url.host; this.protocol = url.protocol as Protocol; this.headers = { Accept: 'application/json', ...headers }; // defaulting accept to json @@ -109,7 +102,10 @@ export default class SupersetClientClass { if (guestToken) { this.headers[guestTokenHeaderName] = guestToken; } - this.handleUnauthorized = unauthorizedHandler; + this.handleUnauthorized = + unauthorizedHandler !== undefined + ? unauthorizedHandler + : defaultUnauthorizedHandlerForPrefix(this.appRoot); } async init(force = false): CsrfPromise { @@ -239,7 +235,7 @@ export default class SupersetClientClass { method: 'GET', mode: this.mode, timeout: this.timeout, - url: this.getUrl({ endpoint: 'api/v1/security/csrf_token/' }), + url: this.getUrl({ endpoint: '/api/v1/security/csrf_token/' }), parseMethod: 'json', }).then(({ json }) => { if (typeof json === 'object') { @@ -271,7 +267,7 @@ export default class SupersetClientClass { const host = inputHost ?? this.host; const cleanHost = host.slice(-1) === '/' ? host.slice(0, -1) : host; // no backslash - return `${this.protocol}//${cleanHost}/${ + return `${this.protocol}//${cleanHost}${this.appRoot}/${ endpoint[0] === '/' ? endpoint.slice(1) : endpoint }`; } diff --git a/superset-frontend/packages/superset-ui-core/src/connection/constants.ts b/superset-frontend/packages/superset-ui-core/src/connection/constants.ts index 98a8c385742..2dbdeca1777 100644 --- a/superset-frontend/packages/superset-ui-core/src/connection/constants.ts +++ b/superset-frontend/packages/superset-ui-core/src/connection/constants.ts @@ -19,7 +19,7 @@ import { FetchRetryOptions } from './types'; -export const DEFAULT_BASE_URL = 'http://localhost'; +export const DEFAULT_APP_ROOT = ''; // HTTP status codes export const HTTP_STATUS_OK = 200; diff --git a/superset-frontend/packages/superset-ui-core/src/connection/types.ts b/superset-frontend/packages/superset-ui-core/src/connection/types.ts index a63ffd8b68a..e8e6c97771d 100644 --- a/superset-frontend/packages/superset-ui-core/src/connection/types.ts +++ b/superset-frontend/packages/superset-ui-core/src/connection/types.ts @@ -132,9 +132,9 @@ export type CsrfPromise = Promise; export type Protocol = 'http:' | 'https:'; export interface ClientConfig { - baseUrl?: string; host?: Host; protocol?: Protocol; + appRoot?: string; credentials?: Credentials; csrfToken?: CsrfToken; guestToken?: string; diff --git a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts index 39f148f7bee..f7534e2c5c3 100644 --- a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts @@ -31,7 +31,8 @@ describe('SupersetClientClass', () => { describe('new SupersetClientClass()', () => { it('fallback protocol to https when setting only host', () => { const client = new SupersetClientClass({ host: 'TEST-HOST' }); - expect(client.baseUrl).toEqual('https://test-host'); + expect(client.protocol).toEqual('https:'); + expect(client.host).toEqual('test-host'); }); }); @@ -72,6 +73,15 @@ describe('SupersetClientClass', () => { ); }); + it('constructs a valid url if url, endpoint, and host are all empty and appRoot is defined', () => { + client = new SupersetClientClass({ + protocol: 'https:', + host: 'config_host', + appRoot: '/prefix', + }); + expect(client.getUrl()).toBe('https://config_host/prefix/'); + }); + it('does not throw if url, endpoint, and host are all empty', () => { client = new SupersetClientClass({ protocol: 'https:', host: '' }); expect(client.getUrl()).toBe('https://localhost/'); diff --git a/superset-frontend/packages/superset-ui-core/test/query/api/v1/makeApi.test.ts b/superset-frontend/packages/superset-ui-core/test/query/api/v1/makeApi.test.ts index 286ef35cc03..d7fcf1c04c8 100644 --- a/superset-frontend/packages/superset-ui-core/test/query/api/v1/makeApi.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/query/api/v1/makeApi.test.ts @@ -35,13 +35,13 @@ describe('makeApi()', () => { expect(api.requestType).toEqual('search'); }); - it('should allow custom client', async () => { + it('should allow custom path', async () => { expect.assertions(2); const api = makeApi({ method: 'GET', endpoint: '/test-custom-client', }); - const client = new SupersetClientClass({ baseUrl: 'http://foo/' }); + const client = new SupersetClientClass({ appRoot: '/foo' }); const mockResponse = { yes: 'ok' }; const mockRequest = jest.fn(() => Promise.resolve( diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/PopKPI.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/PopKPI.tsx index 63fef6432a5..511ae2308f4 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/PopKPI.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/PopKPI.tsx @@ -81,6 +81,8 @@ export default function PopKPI(props: PopKPIProps) { currentTimeRangeFilter, startDateOffset, shift, + subtitle, + subtitleFontSize, dashboardTimeRange, } = props; @@ -140,6 +142,16 @@ export default function PopKPI(props: PopKPIProps) { margin-bottom: ${theme.sizeUnit * 4}px; `; + const SubtitleText = styled.div` + ${({ theme }) => ` + font-family: ${theme.fontFamily}; + font-weight: ${theme.fontWeightNormal}; + text-align: center; + margin-top: -10px; + margin-bottom: ${theme.sizeUnit * 4}px; + `} + `; + const getArrowIndicatorColor = () => { if (!comparisonColorEnabled || percentDifferenceNumber === 0) { return theme.colors.grayscale.base; @@ -193,31 +205,40 @@ export default function PopKPI(props: PopKPIProps) { ]); const SYMBOLS_WITH_VALUES = useMemo( - () => [ - { - symbol: '#', - value: prevNumber, - tooltipText: t('Data for %s', comparisonRange || 'previous range'), - columnKey: 'Previous value', - }, - { - symbol: '△', - value: valueDifference, - tooltipText: t('Value difference between the time periods'), - columnKey: 'Delta', - }, - { - symbol: '%', - value: percentDifferenceFormattedString, - tooltipText: t('Percentage difference between the time periods'), - columnKey: 'Percent change', - }, - ], + () => + [ + { + defaultSymbol: '#', + value: prevNumber, + tooltipText: t('Data for %s', comparisonRange || 'previous range'), + columnKey: 'Previous value', + }, + { + defaultSymbol: '△', + value: valueDifference, + tooltipText: t('Value difference between the time periods'), + columnKey: 'Delta', + }, + { + defaultSymbol: '%', + value: percentDifferenceFormattedString, + tooltipText: t('Percentage difference between the time periods'), + columnKey: 'Percent change', + }, + ].map(item => { + const config = props.columnConfig?.[item.columnKey]; + return { + ...item, + symbol: config?.displayTypeIcon === false ? '' : item.defaultSymbol, + label: config?.customColumnName || item.columnKey, + }; + }), [ comparisonRange, prevNumber, valueDifference, percentDifferenceFormattedString, + props.columnConfig, ], ); @@ -248,6 +269,15 @@ export default function PopKPI(props: PopKPIProps) { )} + {subtitle && ( + + {subtitle} + + )} {visibleSymbols.length > 0 && (
{visibleSymbols.map((symbol_with_value, index) => ( - 0 ? backgroundColor : defaultBackgroundColor - } - textColor={index > 0 ? textColor : defaultTextColor} - > - {symbol_with_value.symbol} - - {symbol_with_value.value} + {symbol_with_value.symbol && ( + 0 ? backgroundColor : defaultBackgroundColor + } + textColor={index > 0 ? textColor : defaultTextColor} + > + {symbol_with_value.symbol} + + )} + {symbol_with_value.value}{' '} + {props.columnConfig?.[symbol_with_value.columnKey] + ?.customColumnName || ''} ))} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/controlPanel.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/controlPanel.ts index bb285a70b0c..63c126216b2 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/controlPanel.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/controlPanel.ts @@ -23,7 +23,12 @@ import { sharedControls, sections, } from '@superset-ui/chart-controls'; -import { headerFontSize, subheaderFontSize } from '../sharedControls'; +import { + headerFontSize, + subheaderFontSize, + subtitleControl, + subtitleFontSize, +} from '../sharedControls'; import { ColorSchemeEnum } from './types'; const config: ControlPanelConfig = { @@ -63,6 +68,8 @@ const config: ControlPanelConfig = { config: { ...headerFontSize.config, default: 0.2 }, }, ], + [subtitleControl], + [subtitleFontSize], [ { ...subheaderFontSize, @@ -120,7 +127,11 @@ const config: ControlPanelConfig = { [GenericDataType.Numeric]: [ { tab: t('General'), - children: [['visible']], + children: [ + ['customColumnName'], + ['displayTypeIcon'], + ['visible'], + ], }, ], }, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts index 9adf3e1fba7..a40f18b24c3 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts @@ -89,6 +89,8 @@ export default function transformProps(chartProps: ChartProps) { comparisonColorScheme, comparisonColorEnabled, percentDifferenceFormat, + subtitle = '', + subtitleFontSize, columnConfig, } = formData; const { data: dataA = [] } = queriesData[0]; @@ -183,6 +185,8 @@ export default function transformProps(chartProps: ChartProps) { valueDifference, percentDifferenceFormattedString: percentDifference, boldText, + subtitle, + subtitleFontSize, headerFontSize: getHeaderFontSize(headerFontSize), subheaderFontSize: getComparisonFontSize(subheaderFontSize), headerText, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/types.ts index 8036447aa83..e03eaca3243 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/types.ts @@ -36,6 +36,8 @@ export interface PopKPIStylesProps { export type TableColumnConfig = { visible?: boolean; + customColumnName?: string; + displayTypeIcon?: boolean; }; interface PopKPICustomizeProps { @@ -62,6 +64,8 @@ export type PopKPIProps = PopKPIStylesProps & metricName: string; bigNumber: string; prevNumber: string; + subtitle?: string; + subtitleFontSize: number; valueDifference: string; percentDifferenceFormattedString: string; compType: string; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts index b466ae04f17..f9b53ccaacf 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts @@ -24,7 +24,11 @@ import { Dataset, getStandardizedControls, } from '@superset-ui/chart-controls'; -import { headerFontSize, subheaderFontSize } from '../sharedControls'; +import { + headerFontSize, + subtitleFontSize, + subtitleControl, +} from '../sharedControls'; export default { controlPanelSections: [ @@ -33,32 +37,13 @@ export default { expanded: true, controlSetRows: [['metric'], ['adhoc_filters']], }, - { - label: t('Display settings'), - expanded: true, - tabOverride: 'data', - controlSetRows: [ - [ - { - name: 'subheader', - config: { - type: 'TextControl', - label: t('Subheader'), - renderTrigger: true, - description: t( - 'Description text that shows up below your Big Number', - ), - }, - }, - ], - ], - }, { label: t('Chart Options'), expanded: true, controlSetRows: [ [headerFontSize], - [subheaderFontSize], + [subtitleControl], + [subtitleFontSize], ['y_axis_format'], ['currency_format'], [ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts index 757ecd3e612..c673ebd9f5e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts @@ -47,8 +47,8 @@ export default function transformProps( const { headerFontSize, metric = 'value', - subheader = '', - subheaderFontSize, + subtitle = '', + subtitleFontSize, forceTimestampFormatting, timeFormat, yAxisFormat, @@ -59,7 +59,7 @@ export default function transformProps( const { data = [], coltypes = [] } = queriesData[0]; const granularity = extractTimegrain(rawFormData as QueryFormData); const metricName = getMetricLabel(metric); - const formattedSubheader = subheader; + const formattedSubtitle = subtitle; const bigNumber = data.length === 0 ? null : parseMetricValue(data[0][metricName]); @@ -105,8 +105,10 @@ export default function transformProps( bigNumber, headerFormatter, headerFontSize, - subheaderFontSize, - subheader: formattedSubheader, + subtitleFontSize, + subtitle: formattedSubtitle, + subheader: '', + subheaderFontSize: subtitleFontSize, onContextMenu, refs, colorThresholdFormatters, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx index 87459e4bb16..a5d106d3353 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx @@ -231,6 +231,40 @@ class BigNumberVis extends PureComponent { return null; } + renderSubtitle(maxHeight: number) { + const { subtitle, width } = this.props; + let fontSize = 0; + + if (subtitle) { + const container = this.createTemporaryContainer(); + document.body.append(container); + try { + fontSize = computeMaxFontSize({ + text: subtitle, + maxWidth: width * 0.9, + maxHeight, + className: 'subtitle-line', + container, + }); + } finally { + container.remove(); + } + + return ( +
+ {subtitle} +
+ ); + } + return null; + } + renderTrendline(maxHeight: number) { const { width, trendLineData, echartOptions, refs } = this.props; @@ -284,6 +318,7 @@ class BigNumberVis extends PureComponent { kickerFontSize, headerFontSize, subheaderFontSize, + subtitleFontSize, } = this.props; const className = this.getClassName(); @@ -308,6 +343,9 @@ class BigNumberVis extends PureComponent { subheaderFontSize * (1 - PROPORTION.TRENDLINE) * height, ), )} + {this.renderSubtitle( + Math.ceil(subtitleFontSize * (1 - PROPORTION.TRENDLINE) * height), + )}
{this.renderTrendline(chartHeight)} @@ -320,6 +358,7 @@ class BigNumberVis extends PureComponent { {this.renderKicker((kickerFontSize || 0) * height)} {this.renderHeader(Math.ceil(headerFontSize * height))} {this.renderSubheader(Math.ceil(subheaderFontSize * height))} + {this.renderSubtitle(Math.ceil(subtitleFontSize * height))} ); } @@ -370,7 +409,12 @@ export default styled(BigNumberVis)` .subheader-line { line-height: 1em; - padding-bottom: 0; + padding-bottom: 0.3em; + } + + .subtitle-line { + line-height: 1em; + padding-top: 0.3em; } &.is-fallback-value { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx index ea8f9c66f48..7f04a2efebd 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx @@ -26,7 +26,12 @@ import { getStandardizedControls, temporalColumnMixin, } from '@superset-ui/chart-controls'; -import { headerFontSize, subheaderFontSize } from '../sharedControls'; +import { + headerFontSize, + subheaderFontSize, + subtitleFontSize, + subtitleControl, +} from '../sharedControls'; const config: ControlPanelConfig = { controlPanelSections: [ @@ -134,6 +139,8 @@ const config: ControlPanelConfig = { ['color_picker', null], [headerFontSize], [subheaderFontSize], + [subtitleControl], + [subtitleFontSize], ['y_axis_format'], ['currency_format'], [ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts index 53a44d9e3b0..3d933208fd7 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts @@ -66,6 +66,8 @@ export default function transformProps( metric = 'value', showTimestamp, showTrendLine, + subtitle = '', + subtitleFontSize, aggregation, startYAxisAtZero, subheader = '', @@ -302,6 +304,8 @@ export default function transformProps( formatTime, formData, headerFontSize, + subtitleFontSize, + subtitle, subheaderFontSize, mainColor, showTimestamp, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/sharedControls.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/sharedControls.ts index 9cd6032affd..09766ed4bf1 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/sharedControls.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/sharedControls.ts @@ -55,6 +55,39 @@ export const headerFontSize: CustomControlItem = { }, }; +export const subtitleFontSize: CustomControlItem = { + name: 'subtitle_font_size', + config: { + type: 'SelectControl', + label: t('Subtitle Font Size'), + renderTrigger: true, + clearable: false, + default: 0.15, + // Values represent the percentage of space a subtitle should take + options: [ + { + label: t('Tiny'), + value: 0.125, + }, + { + label: t('Small'), + value: 0.15, + }, + { + label: t('Normal'), + value: 0.2, + }, + { + label: t('Large'), + value: 0.3, + }, + { + label: t('Huge'), + value: 0.4, + }, + ], + }, +}; export const subheaderFontSize: CustomControlItem = { name: 'subheader_font_size', config: { @@ -88,3 +121,13 @@ export const subheaderFontSize: CustomControlItem = { ], }, }; + +export const subtitleControl: CustomControlItem = { + name: 'subtitle', + config: { + type: 'TextControl', + label: t('Subtitle'), + renderTrigger: true, + description: t('Description text that shows up below your Big Number'), + }, +}; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts index 7c4908adac1..d843ee1fd46 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts @@ -78,7 +78,9 @@ export type BigNumberVizProps = { headerFontSize: number; kickerFontSize?: number; subheader: string; + subtitle: string; subheaderFontSize: number; + subtitleFontSize: number; showTimestamp?: boolean; showTrendLine?: boolean; startYAxisAtZero?: boolean; diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx index f2e2fe9aab5..590e4b862a7 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx @@ -682,13 +682,33 @@ export default function TableChart( (column: DataColumnMeta, i: number): ColumnWithLooseAccessor => { const { key, - label, + label: originalLabel, isNumeric, dataType, isMetric, isPercentMetric, config = {}, } = column; + const label = config.customColumnName || originalLabel; + let displayLabel = label; + + const isComparisonColumn = ['#', 'â–ł', '%', t('Main')].includes( + column.label, + ); + + if (isComparisonColumn) { + if (column.label === t('Main')) { + displayLabel = config.customColumnName || column.originalLabel || ''; + } else if (config.customColumnName) { + displayLabel = + config.displayTypeIcon !== false + ? `${column.label} ${config.customColumnName}` + : config.customColumnName; + } else if (config.displayTypeIcon === false) { + displayLabel = ''; + } + } + const columnWidth = Number.isNaN(Number(config.columnWidth)) ? config.columnWidth : Number(config.columnWidth); @@ -797,6 +817,9 @@ export default function TableChart( white-space: ${value instanceof Date ? 'nowrap' : undefined}; position: relative; background: ${backgroundColor || undefined}; + padding-left: ${column.isChildColumn + ? `${theme.sizeUnit * 5}px` + : `${theme.sizeUnit}px`}; `; const cellBarStyles = css` @@ -972,11 +995,12 @@ export default function TableChart( alignItems: 'flex-end', }} > - {label} + {displayLabel} ), + Footer: totals ? ( i === 0 ? ( @@ -1026,9 +1050,14 @@ export default function TableChart( ], ); + const visibleColumnsMeta = useMemo( + () => filteredColumnsMeta.filter(col => col.config?.visible !== false), + [filteredColumnsMeta], + ); + const columns = useMemo( - () => filteredColumnsMeta.map(getColumnConfigs), - [filteredColumnsMeta, getColumnConfigs], + () => visibleColumnsMeta.map(getColumnConfigs), + [visibleColumnsMeta, getColumnConfigs], ); const handleServerPaginationChange = useCallback( diff --git a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx index ef3a8e700c2..c9e3a7b628b 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx @@ -494,6 +494,7 @@ const config: ControlPanelConfig = { chart?.queriesResponse?.[0] ?? {}; let colnames: string[] = _colnames || []; let coltypes: GenericDataType[] = _coltypes || []; + const childColumnMap: Record = {}; if (timeComparisonStatus) { /** @@ -501,15 +502,27 @@ const config: ControlPanelConfig = { */ const updatedColnames: string[] = []; const updatedColtypes: GenericDataType[] = []; + colnames.forEach((colname, index) => { if (coltypes[index] === GenericDataType.Numeric) { - updatedColnames.push( - ...generateComparisonColumns(colname), - ); - updatedColtypes.push(...generateComparisonColumnTypes(4)); + const comparisonColumns = + generateComparisonColumns(colname); + comparisonColumns.forEach((name, idx) => { + updatedColnames.push(name); + updatedColtypes.push( + ...generateComparisonColumnTypes(4), + ); + + if (idx === 0 && name.startsWith('Main ')) { + childColumnMap[name] = false; + } else { + childColumnMap[name] = true; + } + }); } else { updatedColnames.push(colname); updatedColtypes.push(coltypes[index]); + childColumnMap[colname] = false; } }); @@ -517,7 +530,7 @@ const config: ControlPanelConfig = { coltypes = updatedColtypes; } return { - columnsPropsObject: { colnames, coltypes }, + columnsPropsObject: { colnames, coltypes, childColumnMap }, }; }, }, diff --git a/superset-frontend/plugins/plugin-chart-table/src/types.ts b/superset-frontend/plugins/plugin-chart-table/src/types.ts index 62a666a88e7..7460a27c461 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/types.ts @@ -49,6 +49,9 @@ export type TableColumnConfig = { colorPositiveNegative?: boolean; truncateLongCells?: boolean; currencyFormat?: Currency; + visible?: boolean; + customColumnName?: string; + displayTypeIcon?: boolean; }; export interface DataColumnMeta { @@ -68,6 +71,7 @@ export interface DataColumnMeta { isPercentMetric?: boolean; isNumeric?: boolean; config?: TableColumnConfig; + isChildColumn?: boolean; } export interface TableChartData { diff --git a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx index 13d0f1daaba..f3a24d24f63 100644 --- a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx +++ b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx @@ -67,18 +67,21 @@ describe('plugin-chart-table', () => { }); it('should process comparison columns when time_compare and comparison_type are set', () => { const transformedProps = transformProps(testData.comparison); - - // Check if comparison columns are processed const comparisonColumns = transformedProps.columns.filter( col => - col.label === 'Main' || + col.originalLabel === 'metric_1' || + col.originalLabel === 'metric_2' || col.label === '#' || col.label === 'â–ł' || col.label === '%', ); - expect(comparisonColumns.length).toBeGreaterThan(0); - expect(comparisonColumns.some(col => col.label === 'Main')).toBe(true); + expect( + comparisonColumns.some(col => col.originalLabel === 'metric_1'), + ).toBe(true); + expect( + comparisonColumns.some(col => col.originalLabel === 'metric_2'), + ).toBe(true); expect(comparisonColumns.some(col => col.label === '#')).toBe(true); expect(comparisonColumns.some(col => col.label === 'â–ł')).toBe(true); expect(comparisonColumns.some(col => col.label === '%')).toBe(true); @@ -180,26 +183,37 @@ describe('plugin-chart-table', () => { const transformedProps = transformProps(testData.comparison); // Check if comparison columns are processed + // Now we're looking for columns with metric names as labels const comparisonColumns = transformedProps.columns.filter( col => - col.label === 'Main' || + col.originalLabel === 'metric_1' || + col.originalLabel === 'metric_2' || col.label === '#' || col.label === 'â–ł' || col.label === '%', ); expect(comparisonColumns.length).toBeGreaterThan(0); - expect(comparisonColumns.some(col => col.label === 'Main')).toBe(true); + expect( + comparisonColumns.some(col => col.originalLabel === 'metric_1'), + ).toBe(true); + expect( + comparisonColumns.some(col => col.originalLabel === 'metric_2'), + ).toBe(true); expect(comparisonColumns.some(col => col.label === '#')).toBe(true); expect(comparisonColumns.some(col => col.label === 'â–ł')).toBe(true); expect(comparisonColumns.some(col => col.label === '%')).toBe(true); - // Verify originalLabel for metric_1 comparison columns - const mainMetric1 = transformedProps.columns.find( - col => col.key === 'Main metric_1', + const metric1Column = transformedProps.columns.find( + col => + col.originalLabel === 'metric_1' && + !col.key.startsWith('#') && + !col.key.startsWith('â–ł') && + !col.key.startsWith('%'), ); - expect(mainMetric1).toBeDefined(); - expect(mainMetric1?.originalLabel).toBe('metric_1'); + expect(metric1Column).toBeDefined(); + expect(metric1Column?.originalLabel).toBe('metric_1'); + expect(metric1Column?.label).toBe('Main'); const hashMetric1 = transformedProps.columns.find( col => col.key === '# metric_1', @@ -220,11 +234,17 @@ describe('plugin-chart-table', () => { expect(percentMetric1?.originalLabel).toBe('metric_1'); // Verify originalLabel for metric_2 comparison columns - const mainMetric2 = transformedProps.columns.find( - col => col.key === 'Main metric_2', + const metric2Column = transformedProps.columns.find( + col => + col.originalLabel === 'metric_2' && + !col.key.startsWith('#') && + !col.key.startsWith('â–ł') && + !col.key.startsWith('%'), ); - expect(mainMetric2).toBeDefined(); - expect(mainMetric2?.originalLabel).toBe('metric_2'); + expect(metric2Column).toBeDefined(); + expect(metric2Column?.originalLabel).toBe('metric_2'); + + expect(metric2Column?.label).toBe('Main'); const hashMetric2 = transformedProps.columns.find( col => col.key === '# metric_2', @@ -244,302 +264,301 @@ describe('plugin-chart-table', () => { expect(percentMetric2).toBeDefined(); expect(percentMetric2?.originalLabel).toBe('metric_2'); }); - }); - describe('TableChart', () => { - it('render basic data', () => { - render( - - , - , - ); + describe('TableChart', () => { + it('render basic data', () => { + render( + + , + , + ); - const firstDataRow = screen.getAllByRole('rowgroup')[1]; - const cells = firstDataRow.querySelectorAll('td'); - expect(cells).toHaveLength(12); - expect(cells[0]).toHaveTextContent('2020-01-01 12:34:56'); - expect(cells[1]).toHaveTextContent('Michael'); - // number is not in `metrics` list, so it should output raw value - // (in real world Superset, this would mean the column is used in GROUP BY) - expect(cells[2]).toHaveTextContent('2467063'); - // should not render column with `.` in name as `undefined` - expect(cells[3]).toHaveTextContent('foo'); - expect(cells[6]).toHaveTextContent('2467'); - expect(cells[8]).toHaveTextContent('N/A'); - }); - - it('render advanced data', () => { - render( - - , - , - ); - const secondColumnHeader = screen.getByText('Sum of Num'); - expect(secondColumnHeader).toBeInTheDocument(); - expect(secondColumnHeader?.getAttribute('data-column-name')).toEqual('1'); - - const firstDataRow = screen.getAllByRole('rowgroup')[1]; - const cells = firstDataRow.querySelectorAll('td'); - expect(cells[0]).toHaveTextContent('Michael'); - expect(cells[2]).toHaveTextContent('12.346%'); - expect(cells[4]).toHaveTextContent('2.47k'); - }); - - it('render advanced data with currencies', () => { - render( - ProviderWrapper({ - children: ( - - ), - }), - ); - const cells = document.querySelectorAll('td'); - expect(document.querySelectorAll('th')[1]).toHaveTextContent( - 'Sum of Num', - ); - expect(cells[0]).toHaveTextContent('Michael'); - expect(cells[2]).toHaveTextContent('12.346%'); - expect(cells[4]).toHaveTextContent('$ 2.47k'); - }); - - it('render raw data', () => { - const props = transformProps({ - ...testData.raw, - rawFormData: { ...testData.raw.rawFormData }, + const firstDataRow = screen.getAllByRole('rowgroup')[1]; + const cells = firstDataRow.querySelectorAll('td'); + expect(cells).toHaveLength(12); + expect(cells[0]).toHaveTextContent('2020-01-01 12:34:56'); + expect(cells[1]).toHaveTextContent('Michael'); + // number is not in `metrics` list, so it should output raw value + // (in real world Superset, this would mean the column is used in GROUP BY) + expect(cells[2]).toHaveTextContent('2467063'); + // should not render column with `.` in name as `undefined` + expect(cells[3]).toHaveTextContent('foo'); + expect(cells[6]).toHaveTextContent('2467'); + expect(cells[8]).toHaveTextContent('N/A'); }); - render( - ProviderWrapper({ - children: , - }), - ); - const cells = document.querySelectorAll('td'); - expect(document.querySelectorAll('th')[0]).toHaveTextContent('num'); - expect(cells[0]).toHaveTextContent('1234'); - expect(cells[1]).toHaveTextContent('10000'); - expect(cells[1]).toHaveTextContent('0'); - }); - it('render raw data with currencies', () => { - const props = transformProps({ - ...testData.raw, - rawFormData: { - ...testData.raw.rawFormData, - column_config: { - num: { - currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' }, + it('render advanced data', () => { + render( + + + , + , + ); + const secondColumnHeader = screen.getByText('Sum of Num'); + expect(secondColumnHeader).toBeInTheDocument(); + expect(secondColumnHeader?.getAttribute('data-column-name')).toEqual( + '1', + ); + + const firstDataRow = screen.getAllByRole('rowgroup')[1]; + const cells = firstDataRow.querySelectorAll('td'); + expect(cells[0]).toHaveTextContent('Michael'); + expect(cells[2]).toHaveTextContent('12.346%'); + expect(cells[4]).toHaveTextContent('2.47k'); + }); + + it('render advanced data with currencies', () => { + render( + ProviderWrapper({ + children: ( + + ), + }), + ); + const cells = document.querySelectorAll('td'); + expect(document.querySelectorAll('th')[1]).toHaveTextContent( + 'Sum of Num', + ); + expect(cells[0]).toHaveTextContent('Michael'); + expect(cells[2]).toHaveTextContent('12.346%'); + expect(cells[4]).toHaveTextContent('$ 2.47k'); + }); + + it('render raw data', () => { + const props = transformProps({ + ...testData.raw, + rawFormData: { ...testData.raw.rawFormData }, + }); + render( + ProviderWrapper({ + children: , + }), + ); + const cells = document.querySelectorAll('td'); + expect(document.querySelectorAll('th')[0]).toHaveTextContent('num'); + expect(cells[0]).toHaveTextContent('1234'); + expect(cells[1]).toHaveTextContent('10000'); + expect(cells[1]).toHaveTextContent('0'); + }); + + it('render raw data with currencies', () => { + const props = transformProps({ + ...testData.raw, + rawFormData: { + ...testData.raw.rawFormData, + column_config: { + num: { + currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' }, + }, }, }, - }, + }); + render( + ProviderWrapper({ + children: , + }), + ); + const cells = document.querySelectorAll('td'); + + expect(document.querySelectorAll('th')[0]).toHaveTextContent('num'); + expect(cells[0]).toHaveTextContent('$ 1.23k'); + expect(cells[1]).toHaveTextContent('$ 10k'); + expect(cells[2]).toHaveTextContent('$ 0'); }); - render( - ProviderWrapper({ - children: , - }), - ); - const cells = document.querySelectorAll('td'); - expect(document.querySelectorAll('th')[0]).toHaveTextContent('num'); - expect(cells[0]).toHaveTextContent('$ 1.23k'); - expect(cells[1]).toHaveTextContent('$ 10k'); - expect(cells[2]).toHaveTextContent('$ 0'); - }); - - it('render small formatted data with currencies', () => { - const props = transformProps({ - ...testData.raw, - rawFormData: { - ...testData.raw.rawFormData, - column_config: { - num: { - d3SmallNumberFormat: '.2r', - currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' }, + it('render small formatted data with currencies', () => { + const props = transformProps({ + ...testData.raw, + rawFormData: { + ...testData.raw.rawFormData, + column_config: { + num: { + d3SmallNumberFormat: '.2r', + currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' }, + }, }, }, - }, - queriesData: [ - { - ...testData.raw.queriesData[0], - data: [ - { - num: 1234, - }, - { - num: 0.5, - }, - { - num: 0.61234, - }, - ], - }, - ], - }); - render( - ProviderWrapper({ - children: , - }), - ); - const cells = document.querySelectorAll('td'); - - expect(document.querySelectorAll('th')[0]).toHaveTextContent('num'); - expect(cells[0]).toHaveTextContent('$ 1.23k'); - expect(cells[1]).toHaveTextContent('$ 0.50'); - expect(cells[2]).toHaveTextContent('$ 0.61'); - }); - - it('render empty data', () => { - render( - - , - , - ); - expect(screen.getByText('No records found')).toBeInTheDocument(); - }); - - it('render color with column color formatter', () => { - render( - ProviderWrapper({ - children: ( - ', - targetValue: 2467, - }, - ], + queriesData: [ + { + ...testData.raw.queriesData[0], + data: [ + { + num: 1234, }, - })} - /> - ), - }), - ); + { + num: 0.5, + }, + { + num: 0.61234, + }, + ], + }, + ], + }); + render( + ProviderWrapper({ + children: , + }), + ); + const cells = document.querySelectorAll('td'); - expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe( - 'rgba(172, 225, 196, 1)', - ); - expect(getComputedStyle(screen.getByTitle('2467')).background).toBe( - 'rgb(255, 255, 255)', - ); - }); - - it('render cell without color', () => { - const dataWithEmptyCell = testData.advanced.queriesData[0]; - dataWithEmptyCell.data.push({ - __timestamp: null, - name: 'Noah', - sum__num: null, - '%pct_nice': 0.643, - 'abc.com': 'bazzinga', + expect(document.querySelectorAll('th')[0]).toHaveTextContent('num'); + expect(cells[0]).toHaveTextContent('$ 1.23k'); + expect(cells[1]).toHaveTextContent('$ 0.50'); + expect(cells[2]).toHaveTextContent('$ 0.61'); }); - render( - ProviderWrapper({ - children: ( - - ), - }), - ); - expect(getComputedStyle(screen.getByTitle('2467')).background).toBe( - 'rgba(172, 225, 196, 0.812)', - ); - expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe( - 'rgb(255, 255, 255)', - ); - expect(getComputedStyle(screen.getByText('N/A')).background).toBe( - 'rgb(255, 255, 255)', - ); - }); - it('should display originalLabel in grouped headers', () => { - render( - - - , - ); + it('render empty data', () => { + render( + + , + , + ); + expect(screen.getByText('No records found')).toBeInTheDocument(); + }); - const groupHeaders = screen.getAllByRole('columnheader'); - expect(groupHeaders[0]).toHaveTextContent('metric_1'); - expect(groupHeaders[1]).toHaveTextContent('metric_2'); - }); - }); + it('render color with column color formatter', () => { + render( + ProviderWrapper({ + children: ( + ', + targetValue: 2467, + }, + ], + }, + })} + /> + ), + }), + ); - it('render cell bars properly, and only when it is toggled on in both regular and percent metrics', () => { - const props = transformProps({ - ...testData.raw, - rawFormData: { ...testData.raw.rawFormData }, - }); + expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe( + 'rgba(172, 225, 196, 1)', + ); + expect(getComputedStyle(screen.getByTitle('2467')).background).toBe(''); + }); - props.columns[0].isMetric = true; + it('render cell without color', () => { + const dataWithEmptyCell = testData.advanced.queriesData[0]; + dataWithEmptyCell.data.push({ + __timestamp: null, + name: 'Noah', + sum__num: null, + '%pct_nice': 0.643, + 'abc.com': 'bazzinga', + }); - render( - ProviderWrapper({ - children: , - }), - ); - let cells = document.querySelectorAll('div.cell-bar'); - cells.forEach(cell => { - expect(cell).toHaveClass('positive'); - }); - props.columns[0].isMetric = false; - props.columns[0].isPercentMetric = true; + render( + ProviderWrapper({ + children: ( + + ), + }), + ); + expect(getComputedStyle(screen.getByTitle('2467')).background).toBe( + 'rgba(172, 225, 196, 0.812)', + ); + expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe( + '', + ); + expect(getComputedStyle(screen.getByText('N/A')).background).toBe(''); + }); + it('should display originalLabel in grouped headers', () => { + const props = transformProps(testData.comparison); - render( - ProviderWrapper({ - children: , - }), - ); - cells = document.querySelectorAll('div.cell-bar'); - cells.forEach(cell => { - expect(cell).toHaveClass('positive'); - }); + render( + + + , + ); + const groupHeaders = screen.getAllByRole('columnheader'); + expect(groupHeaders.length).toBeGreaterThan(0); + const hasMetricHeaders = groupHeaders.some( + header => + header.textContent && + (header.textContent.includes('metric') || + header.textContent.includes('Metric')), + ); + expect(hasMetricHeaders).toBe(true); + }); - props.showCellBars = false; + it('render cell bars properly, and only when it is toggled on in both regular and percent metrics', () => { + const props = transformProps({ + ...testData.raw, + rawFormData: { ...testData.raw.rawFormData }, + }); - render( - ProviderWrapper({ - children: , - }), - ); - cells = document.querySelectorAll('td'); + props.columns[0].isMetric = true; - cells.forEach(cell => { - expect(cell).toHaveClass('test-70d2p3'); - }); + render( + ProviderWrapper({ + children: , + }), + ); + let cells = document.querySelectorAll('div.cell-bar'); + cells.forEach(cell => { + expect(cell).toHaveClass('positive'); + }); + props.columns[0].isMetric = false; + props.columns[0].isPercentMetric = true; - props.columns[0].isPercentMetric = false; - props.columns[0].isMetric = true; + render( + ProviderWrapper({ + children: , + }), + ); + cells = document.querySelectorAll('div.cell-bar'); + cells.forEach(cell => { + expect(cell).toHaveClass('positive'); + }); - render( - ProviderWrapper({ - children: , - }), - ); - cells = document.querySelectorAll('td'); - cells.forEach(cell => { - expect(cell).toHaveClass('test-70d2p3'); + props.showCellBars = false; + + render( + ProviderWrapper({ + children: , + }), + ); + cells = document.querySelectorAll('td'); + + props.columns[0].isPercentMetric = false; + props.columns[0].isMetric = true; + + render( + ProviderWrapper({ + children: , + }), + ); + cells = document.querySelectorAll('td'); + }); }); }); }); diff --git a/superset-frontend/spec/fixtures/mockNativeFilters.ts b/superset-frontend/spec/fixtures/mockNativeFilters.ts index b83cdcc8dcc..414935c8b36 100644 --- a/superset-frontend/spec/fixtures/mockNativeFilters.ts +++ b/superset-frontend/spec/fixtures/mockNativeFilters.ts @@ -48,6 +48,7 @@ export const nativeFilters: NativeFiltersState = { excluded: [], }, controlValues: { + creatable: false, multiSelect: false, enableEmptyFilter: false, inverseSelection: false, @@ -79,6 +80,7 @@ export const nativeFilters: NativeFiltersState = { excluded: [], }, controlValues: { + creatable: false, multiSelect: false, enableEmptyFilter: false, inverseSelection: false, @@ -463,6 +465,7 @@ export const buildNativeFilter = ( ) => ({ id, controlValues: { + creatable: true, multiSelect: true, enableEmptyFilter: false, defaultToFirstItem: false, diff --git a/superset-frontend/spec/helpers/setupSupersetClient.js b/superset-frontend/spec/helpers/setupSupersetClient.js index c65f684266e..fc08d9e21d1 100644 --- a/superset-frontend/spec/helpers/setupSupersetClient.js +++ b/superset-frontend/spec/helpers/setupSupersetClient.js @@ -26,5 +26,5 @@ export default function setupSupersetClient() { // including CSRF authentication and initialization global.FormData = window.FormData; // used by SupersetClient fetchMock.get('glob:*/api/v1/security/csrf_token/*', { result: '1234' }); - SupersetClient.configure({ protocol: 'http', host: 'localhost' }).init(); + SupersetClient.configure({ protocol: 'http:', host: 'localhost' }).init(); } diff --git a/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx index e6aa9291c0a..0655e592aee 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx @@ -213,12 +213,12 @@ const SqlEditorTabHeader: FC = ({ queryEditor }) => { } /> - {qe.name} + {qe.name}{' '} + />{' '} ); }; diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx index 4105fc68f27..8fec7342c4c 100644 --- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx +++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx @@ -35,6 +35,7 @@ import { detectOS } from 'src/utils/common'; import * as Actions from 'src/SqlLab/actions/sqlLab'; import getBootstrapData from 'src/utils/getBootstrapData'; import { locationContext } from 'src/pages/SqlLab/LocationContext'; +import { navigateWithState } from 'src/utils/navigationUtils'; import { Icons } from 'src/components/Icons'; import SqlEditor from '../SqlEditor'; import SqlEditorTabHeader from '../SqlEditorTabHeader'; @@ -147,7 +148,7 @@ class TabbedSqlEditors extends PureComponent { this.newQueryEditor(); if (isNewQuery) { - window.history.replaceState({}, document.title, SQL_LAB_URL); + navigateWithState(SQL_LAB_URL, {}, { replace: true }); } } else { const qe = this.activeQueryEditor(); @@ -170,7 +171,7 @@ class TabbedSqlEditors extends PureComponent { popNewTab(urlParams: Record) { // Clean the url in browser history const updatedUrl = `${URI(SQL_LAB_URL).query(urlParams)}`; - window.history.replaceState({}, document.title, updatedUrl); + navigateWithState(updatedUrl, {}, { replace: true }); } activeQueryEditor() { diff --git a/superset-frontend/src/components/Chart/chartAction.js b/superset-frontend/src/components/Chart/chartAction.js index 2398eafe361..a12ae44e2b1 100644 --- a/superset-frontend/src/components/Chart/chartAction.js +++ b/superset-frontend/src/components/Chart/chartAction.js @@ -41,6 +41,7 @@ import { Logger, LOG_ACTIONS_LOAD_CHART } from 'src/logger/LogUtils'; import { allowCrossDomain as domainShardingEnabled } from 'src/utils/hostNamesConfig'; import { updateDataMask } from 'src/dataMask/actions'; import { waitForAsyncData } from 'src/middleware/asyncEvent'; +import { ensureAppRoot } from 'src/utils/pathUtils'; import { safeStringify } from 'src/utils/safeStringify'; import { extendedDayjs } from 'src/utils/dates'; @@ -551,7 +552,7 @@ export function redirectSQLLab(formData, history) { }, }); } else { - SupersetClient.postForm(redirectUrl, { + SupersetClient.postForm(ensureAppRoot(redirectUrl), { form_data: safeStringify(payload), }); } diff --git a/superset-frontend/src/components/FacePile/index.tsx b/superset-frontend/src/components/FacePile/index.tsx index f060e41ed28..8f59e9d6cb5 100644 --- a/superset-frontend/src/components/FacePile/index.tsx +++ b/superset-frontend/src/components/FacePile/index.tsx @@ -23,8 +23,8 @@ import { FeatureFlag, } from '@superset-ui/core'; import getOwnerName from 'src/utils/getOwnerName'; -import { Tooltip } from '../Tooltip'; -import { Avatar, AvatarGroup } from '../Avatar'; +import { Avatar, AvatarGroup, Tooltip } from 'src/components'; +import { ensureAppRoot } from 'src/utils/pathUtils'; import { getRandomColor } from './utils'; import type { FacePileProps } from './types'; @@ -39,7 +39,7 @@ export function FacePile({ users, maxCount = 4 }: FacePileProps) { const uniqueKey = `${id}-${first_name}-${last_name}`; const color = getRandomColor(uniqueKey, colorList); const avatarUrl = isFeatureEnabled(FeatureFlag.SlackEnableAvatars) - ? `/api/v1/user/${id}/avatar.png` + ? ensureAppRoot(`/api/v1/user/${id}/avatar.png`) : undefined; return ( diff --git a/superset-frontend/src/components/Tag/Tag.test.tsx b/superset-frontend/src/components/Tag/Tag.test.tsx index 115f5150196..b130432694d 100644 --- a/superset-frontend/src/components/Tag/Tag.test.tsx +++ b/superset-frontend/src/components/Tag/Tag.test.tsx @@ -28,13 +28,16 @@ const mockedProps: TagType = { onClick: undefined, }; +const setup = (props: TagType = mockedProps) => + render(, { useRouter: true }); + test('should render', () => { - const { container } = render(); + const { container } = setup(); expect(container).toBeInTheDocument(); }); test('should render shortname properly', () => { - const { container } = render(); + const { container } = setup(); expect(container).toBeInTheDocument(); expect(screen.getByTestId('tag')).toBeInTheDocument(); expect(screen.getByTestId('tag')).toHaveTextContent(mockedProps.name || ''); @@ -45,7 +48,7 @@ test('should render longname properly', () => { ...mockedProps, name: 'very-long-tag-name-that-truncates', }; - const { container } = render(); + const { container } = setup(longNameProps); expect(container).toBeInTheDocument(); expect(screen.getByTestId('tag')).toBeInTheDocument(); expect(screen.getByTestId('tag')).toHaveTextContent( diff --git a/superset-frontend/src/components/Tag/index.tsx b/superset-frontend/src/components/Tag/index.tsx index 392cdb364cc..495fec5c4a1 100644 --- a/superset-frontend/src/components/Tag/index.tsx +++ b/superset-frontend/src/components/Tag/index.tsx @@ -18,6 +18,7 @@ */ import { styled } from '@superset-ui/core'; +import { Link } from 'react-router-dom'; import TagType from 'src/types/TagType'; import { Tag as AntdTag } from 'antd-v5'; import type { TagProps } from 'antd-v5/es'; @@ -80,13 +81,13 @@ const SupersetTag = ({ > {' '} {id ? ( - {children || tagDisplay} - + ) : ( children || tagDisplay )} diff --git a/superset-frontend/src/components/TagsList/TagsList.test.tsx b/superset-frontend/src/components/TagsList/TagsList.test.tsx index f5797db803b..6d4eb048b64 100644 --- a/superset-frontend/src/components/TagsList/TagsList.test.tsx +++ b/superset-frontend/src/components/TagsList/TagsList.test.tsx @@ -53,13 +53,16 @@ const getElementsByClassName = (className: string) => const findAllTags = () => waitFor(() => getElementsByClassName('.ant-tag')); +const setup = (props: TagsListProps = mockedProps) => + render(, { useRouter: true }); + test('should render', () => { - const { container } = render(); + const { container } = setup(); expect(container).toBeInTheDocument(); }); test('should render 5 elements', async () => { - render(); + setup(); const tagsListItems = await findAllTags(); expect(tagsListItems).toHaveLength(5); expect(tagsListItems[0]).toHaveTextContent(testTags[0].name); @@ -70,7 +73,7 @@ test('should render 5 elements', async () => { }); test('should render 3 elements when maxTags is set to 3', async () => { - render(); + setup({ ...mockedProps, maxTags: 3 }); const tagsListItems = await findAllTags(); expect(tagsListItems).toHaveLength(3); expect(tagsListItems[2]).toHaveTextContent('+3...'); diff --git a/superset-frontend/src/constants.ts b/superset-frontend/src/constants.ts index c4fd0e94f9c..98abb6c2b12 100644 --- a/superset-frontend/src/constants.ts +++ b/superset-frontend/src/constants.ts @@ -143,6 +143,8 @@ export const SLOW_DEBOUNCE = 500; export const NULL_DISPLAY = t('N/A'); export const DEFAULT_COMMON_BOOTSTRAP_DATA: CommonBootstrapData = { + application_root: '/', + static_assets_prefix: '', flash_messages: [], conf: {}, locale: 'en', diff --git a/superset-frontend/src/dashboard/actions/dashboardState.js b/superset-frontend/src/dashboard/actions/dashboardState.js index 33111c75d50..51bdb95784c 100644 --- a/superset-frontend/src/dashboard/actions/dashboardState.js +++ b/superset-frontend/src/dashboard/actions/dashboardState.js @@ -57,6 +57,7 @@ import { safeStringify } from 'src/utils/safeStringify'; import { logEvent } from 'src/logger/actions'; import { LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA } from 'src/logger/LogUtils'; import { isEqual } from 'lodash'; +import { navigateWithState } from 'src/utils/navigationUtils'; import { UPDATE_COMPONENTS_PARENTS_LIST } from './dashboardLayout'; import { saveChartConfiguration, @@ -402,11 +403,9 @@ export function saveDashboardRequest(data, id, saveType) { } dispatch(saveDashboardFinished()); // redirect to the new slug or id - window.history.pushState( - { event: 'dashboard_properties_changed' }, - '', - `/superset/dashboard/${slug || id}/`, - ); + navigateWithState(`/superset/dashboard/${slug || id}/`, { + event: 'dashboard_properties_changed', + }); dispatch(addSuccessToast(t('This dashboard was saved successfully.'))); dispatch(setOverrideConfirm(undefined)); diff --git a/superset-frontend/src/dashboard/components/AddSliceCard/AddSliceCard.tsx b/superset-frontend/src/dashboard/components/AddSliceCard/AddSliceCard.tsx index c66efb95510..8f315cb8d6e 100644 --- a/superset-frontend/src/dashboard/components/AddSliceCard/AddSliceCard.tsx +++ b/superset-frontend/src/dashboard/components/AddSliceCard/AddSliceCard.tsx @@ -36,9 +36,12 @@ import { usePluginContext, ImageLoader, } from 'src/components'; +import { assetUrl } from 'src/utils/assetUrl'; import { Theme } from '@emotion/react'; -const FALLBACK_THUMBNAIL_URL = '/static/assets/images/chart-card-fallback.svg'; +const FALLBACK_THUMBNAIL_URL = assetUrl( + '/static/assets/images/chart-card-fallback.svg', +); const TruncatedTextWithTooltip = ({ children, diff --git a/superset-frontend/src/dashboard/components/DashboardGrid.jsx b/superset-frontend/src/dashboard/components/DashboardGrid.jsx index 8ed4378a562..0fcaa75145b 100644 --- a/superset-frontend/src/dashboard/components/DashboardGrid.jsx +++ b/superset-frontend/src/dashboard/components/DashboardGrid.jsx @@ -23,6 +23,7 @@ import classNames from 'classnames'; import { addAlpha, css, styled, t } from '@superset-ui/core'; import { EmptyState } from 'src/components'; import { Icons } from 'src/components/Icons'; +import { navigateTo } from 'src/utils/navigationUtils'; import { componentShape } from '../util/propShapes'; import DashboardComponent from '../containers/DashboardComponent'; import { Droppable } from './dnd/DragDroppable'; @@ -217,11 +218,9 @@ class DashboardGrid extends PureComponent { } buttonAction={() => { - window.open( - `/chart/add?dashboard_id=${dashboardId}`, - '_blank', - 'noopener noreferrer', - ); + navigateTo(`/chart/add?dashboard_id=${dashboardId}`, { + newWindow: true, + }); }} image="chart.svg" /> @@ -244,11 +243,9 @@ class DashboardGrid extends PureComponent { } buttonAction={() => { - window.open( - `/chart/add?dashboard_id=${dashboardId}`, - '_blank', - 'noopener noreferrer', - ); + navigateTo(`/chart/add?dashboard_id=${dashboardId}`, { + newWindow: true, + }); }} image="chart.svg" /> diff --git a/superset-frontend/src/dashboard/components/SaveModal.tsx b/superset-frontend/src/dashboard/components/SaveModal.tsx index 2cf38b2c989..573e028cc1b 100644 --- a/superset-frontend/src/dashboard/components/SaveModal.tsx +++ b/superset-frontend/src/dashboard/components/SaveModal.tsx @@ -27,6 +27,7 @@ import { SAVE_TYPE_OVERWRITE, SAVE_TYPE_NEWDASHBOARD, } from 'src/dashboard/util/constants'; +import { navigateTo } from 'src/utils/navigationUtils'; type SaveType = typeof SAVE_TYPE_OVERWRITE | typeof SAVE_TYPE_NEWDASHBOARD; @@ -151,7 +152,7 @@ class SaveModal extends PureComponent { } else { this.onSave(data, dashboardId, saveType).then((resp: JsonResponse) => { if (saveType === SAVE_TYPE_NEWDASHBOARD && resp.json?.result?.id) { - window.location.href = `/superset/dashboard/${resp.json.result.id}/`; + navigateTo(`/superset/dashboard/${resp.json.result.id}/`); } }); this.modal?.current?.close?.(); diff --git a/superset-frontend/src/dashboard/components/SliceAdder.tsx b/superset-frontend/src/dashboard/components/SliceAdder.tsx index 6550f602408..c36bc9464d3 100644 --- a/superset-frontend/src/dashboard/components/SliceAdder.tsx +++ b/superset-frontend/src/dashboard/components/SliceAdder.tsx @@ -43,6 +43,7 @@ import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; import { Dispatch } from 'redux'; import { Slice } from 'src/dashboard/types'; import { withTheme, Theme } from '@emotion/react'; +import { navigateTo } from 'src/utils/navigationUtils'; import AddSliceCard from './AddSliceCard'; import AddSliceDragPreview from './dnd/AddSliceDragPreview'; import { DragDroppable } from './dnd/DragDroppable'; @@ -360,11 +361,9 @@ class SliceAdder extends Component { buttonStyle="link" buttonSize="xsmall" onClick={() => - window.open( - `/chart/add?dashboard_id=${this.props.dashboardId}`, - '_blank', - 'noopener noreferrer', - ) + navigateTo(`/chart/add?dashboard_id=${this.props.dashboardId}`, { + newWindow: true, + }) } > { - let endpoint = `api/v1/dashboard/${dashId}/filter_state`; + let endpoint = `/api/v1/dashboard/${dashId}/filter_state`; if (key) { endpoint = endpoint.concat(`/${key}`); } diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx index d380ac71e40..030eca2fe1d 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx @@ -136,6 +136,7 @@ type ControlKey = keyof PluginFilterSelectCustomizeProps; const controlsOrder: ControlKey[] = [ 'enableEmptyFilter', 'defaultToFirstItem', + 'creatable', 'multiSelect', 'searchAllOptions', 'inverseSelection', diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx index ef00670038e..51a3af8897e 100644 --- a/superset-frontend/src/embedded/index.tsx +++ b/superset-frontend/src/embedded/index.tsx @@ -16,6 +16,8 @@ * specific language governing permissions and limitations * under the License. */ +import 'src/public-path'; + import { lazy, Suspense } from 'react'; import ReactDOM from 'react-dom'; import { BrowserRouter as Router, Route } from 'react-router-dom'; diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index 5c44887af46..172f8671946 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -49,6 +49,7 @@ import { LOG_ACTIONS_MOUNT_EXPLORER, LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS, } from 'src/logger/LogUtils'; +import { ensureAppRoot } from 'src/utils/pathUtils'; import { getUrlParam } from 'src/utils/urlUtils'; import cx from 'classnames'; import * as chartActions from 'src/components/Chart/chartAction'; @@ -212,7 +213,7 @@ const updateHistory = debounce( stateModifier = 'pushState'; } // avoid race condition in case user changes route before explore updates the url - if (window.location.pathname.startsWith('/explore')) { + if (window.location.pathname.startsWith(ensureAppRoot('/explore'))) { const url = mountExploreUrl( standalone ? URL_PARAMS.standalone.name : null, { diff --git a/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.tsx b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.tsx index 4df4e240d12..b998b75a1d5 100644 --- a/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.tsx +++ b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.tsx @@ -34,7 +34,11 @@ import ControlHeader from '../../ControlHeader'; export type ColumnConfigControlProps = ControlComponentProps> & { - columnsPropsObject?: { colnames: string[]; coltypes: GenericDataType[] }; + columnsPropsObject?: { + colnames: string[]; + coltypes: GenericDataType[]; + childColumnMap?: Record; + }; configFormLayout?: ColumnConfigFormLayout; appliedColumnNames?: string[]; width?: number | string; @@ -82,10 +86,12 @@ export default function ColumnConfigControl({ name: COLUMN_NAME_ALIASES[col] || col, type: coltypes?.[idx], config: value?.[col] || {}, + isChildColumn: columnsPropsObject?.childColumnMap?.[col] ?? false, }; }); return configs; - }, [value, colnames, coltypes]); + }, [value, colnames, coltypes, columnsPropsObject?.childColumnMap]); + const [showAllColumns, setShowAllColumns] = useState(false); const getColumnInfo = (col: string) => columnConfigs[col] || {}; @@ -113,6 +119,8 @@ export default function ColumnConfigControl({ ? colnames.slice(0, MAX_NUM_COLS) : colnames; + const columnsWithChildInfo = cols.map(col => getColumnInfo(col)); + return ( <> @@ -122,12 +130,30 @@ export default function ColumnConfigControl({ borderRadius: theme.sizeUnit, }} > - {cols.map(col => ( + {columnsWithChildInfo.map(col => ( setColumnConfig(col, config as T)} - configFormLayout={configFormLayout} + key={col.name} + column={col} + onChange={config => setColumnConfig(col.name, config as T)} + configFormLayout={ + col.isChildColumn + ? ({ + [col.type ?? GenericDataType.String]: [ + { + tab: 'General', + children: [ + ['customColumnName'], + ['displayTypeIcon'], + ['visible'], + ], + }, + ...(configFormLayout?.[ + col.type ?? GenericDataType.String + ] ?? []), + ], + } as ColumnConfigFormLayout) + : configFormLayout + } width={width} height={height} /> diff --git a/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigItem.tsx b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigItem.tsx index c6b80c2d622..a94c17b43e8 100644 --- a/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigItem.tsx +++ b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigItem.tsx @@ -17,8 +17,9 @@ * under the License. */ import { memo } from 'react'; -import { useTheme } from '@superset-ui/core'; +import { css, useTheme } from '@superset-ui/core'; import Popover from 'src/components/Popover'; +import { Icons } from 'src/components/Icons'; import { ColumnTypeLabel } from '@superset-ui/chart-controls'; import ColumnConfigPopover, { ColumnConfigPopoverProps, @@ -35,6 +36,59 @@ export default memo(function ColumnConfigItem({ }: ColumnConfigItemProps) { const { colors, sizeUnit } = useTheme(); const caretWidth = sizeUnit * 6; + + const outerContainerStyle = css({ + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + padding: `${sizeUnit}px ${2 * sizeUnit}px`, + borderBottom: `1px solid ${colors.grayscale.light2}`, + position: 'relative', + paddingRight: `${caretWidth}px`, + ':last-child': { + borderBottom: 'none', + }, + ':hover': { + background: colors.grayscale.light4, + }, + '> .fa': { + color: colors.grayscale.light2, + }, + ':hover > .fa': { + color: colors.grayscale.light1, + }, + }); + + const nameContainerStyle = css({ + display: 'flex', + alignItems: 'center', + paddingLeft: column.isChildColumn ? sizeUnit * 7 : sizeUnit, + flex: 1, + }); + + const nameTextStyle = css({ + paddingLeft: sizeUnit, + }); + + const iconContainerStyle = css({ + display: 'flex', + alignItems: 'center', + position: 'absolute', + right: 3 * sizeUnit, + top: 3 * sizeUnit, + transform: 'translateY(-50%)', + gap: sizeUnit, + color: colors.grayscale.light1, + }); + + const theme = useTheme(); + + const caretIconStyle = css({ + fontSize: `${theme.fontSizeSM}px`, + fontWeight: theme.fontWeightNormal, + color: theme.colorIcon, + }); + return ( -
.fa': { - color: colors.grayscale.light2, - }, - '&:hover > .fa': { - color: colors.grayscale.light1, - }, - }} - > - - {column.name} - {/* TODO: Remove fa-icon */} - {/* eslint-disable-next-line icons/no-fa-icons-usage */} - +
+
+ + {column.name} +
+ +
+ {column.isChildColumn && column.config?.visible === false && ( + + )} + +
); diff --git a/superset-frontend/src/explore/components/controls/ColumnConfigControl/constants.tsx b/superset-frontend/src/explore/components/controls/ColumnConfigControl/constants.tsx index 2d98f1bb434..51dc19fe970 100644 --- a/superset-frontend/src/explore/components/controls/ColumnConfigControl/constants.tsx +++ b/superset-frontend/src/explore/components/controls/ColumnConfigControl/constants.tsx @@ -38,8 +38,10 @@ export type SharedColumnConfigProp = | 'horizontalAlign' | 'truncateLongCells' | 'showCellBars' - | 'currencyFormat' - | 'visible'; + | 'visible' + | 'customColumnName' + | 'displayTypeIcon' + | 'currencyFormat'; const d3NumberFormat: ControlFormItemSpec<'Select'> = { allowNewOptions: true, @@ -137,6 +139,21 @@ const colorPositiveNegative: ControlFormItemSpec<'Checkbox'> = { debounceDelay: 200, }; +const customColumnName: ControlFormItemSpec<'Input'> = { + controlType: 'Input', + label: t('Display column name'), + description: t('Custom column name (leave blank for default)'), + debounceDelay: 200, +}; + +const displayTypeIcon: ControlFormItemSpec<'Checkbox'> = { + controlType: 'Checkbox', + label: t('Display type icon'), + description: t('Whether to display the type icon (#, Δ, %)'), + defaultValue: true, + debounceDelay: 200, +}; + const truncateLongCells: ControlFormItemSpec<'Checkbox'> = { controlType: 'Checkbox', label: t('Truncate Cells'), @@ -156,7 +173,7 @@ const currencyFormat: ControlFormItemSpec<'CurrencyControl'> = { const visible: ControlFormItemSpec<'Checkbox'> = { controlType: 'Checkbox', - label: t('Display in chart'), + label: t('Display column in the chart'), description: t('Whether to display in the chart'), defaultValue: true, debounceDelay: 200, @@ -177,6 +194,8 @@ export const SHARED_COLUMN_CONFIG_PROPS = { d3TimeFormat, fractionDigits, columnWidth, + customColumnName, + displayTypeIcon, truncateLongCells, horizontalAlign, showCellBars, @@ -196,7 +215,7 @@ export const DEFAULT_CONFIG_FORM_LAYOUT: ColumnConfigFormLayout = { ], [GenericDataType.Numeric]: [ { - tab: t('Display'), + tab: t('Column Settings'), children: [ [ 'columnWidth', diff --git a/superset-frontend/src/explore/components/controls/ColumnConfigControl/types.ts b/superset-frontend/src/explore/components/controls/ColumnConfigControl/types.ts index 34100e93147..5a8c4b5c393 100644 --- a/superset-frontend/src/explore/components/controls/ColumnConfigControl/types.ts +++ b/superset-frontend/src/explore/components/controls/ColumnConfigControl/types.ts @@ -40,6 +40,7 @@ export type ColumnConfig = { * formatting. */ export interface ColumnConfigInfo { + isChildColumn: boolean; name: string; type?: GenericDataType; config: JsonObject; diff --git a/superset-frontend/src/explore/exploreUtils/getChartDataUri.test.ts b/superset-frontend/src/explore/exploreUtils/getChartDataUri.test.ts index 797b5def3d3..c4f0e82bdc8 100644 --- a/superset-frontend/src/explore/exploreUtils/getChartDataUri.test.ts +++ b/superset-frontend/src/explore/exploreUtils/getChartDataUri.test.ts @@ -16,58 +16,67 @@ * specific language governing permissions and limitations * under the License. */ +import { ensureAppRoot } from 'src/utils/pathUtils'; import { getChartDataUri } from '.'; -test('Get ChartUri when allowDomainSharding:false', () => { - expect( - getChartDataUri({ - path: '/path', - qs: 'same-string', - allowDomainSharding: false, - }), - ).toEqual({ - _deferred_build: true, - _parts: { - duplicateQueryParameters: false, - escapeQuerySpace: true, - fragment: null, - hostname: 'localhost', - password: null, - path: '/path', - port: '', - preventInvalidHostname: false, - protocol: 'http', - query: 'same-string', - urn: null, - username: null, - }, - _string: '', - }); -}); +jest.mock('src/utils/pathUtils'); -test('Get ChartUri when allowDomainSharding:true', () => { - expect( - getChartDataUri({ - path: '/path-allowDomainSharding-true', - qs: 'same-string-allowDomainSharding-true', - allowDomainSharding: true, - }), - ).toEqual({ - _deferred_build: true, - _parts: { - duplicateQueryParameters: false, - escapeQuerySpace: true, - fragment: null, - hostname: undefined, - password: null, - path: '/path-allowDomainSharding-true', - port: '', - preventInvalidHostname: false, - protocol: 'http', - query: 'same-string-allowDomainSharding-true', - urn: null, - username: null, - }, - _string: '', +describe('Get ChartUri', () => { + (ensureAppRoot as jest.Mock).mockImplementation( + (path: string) => `/prefix${path}`, + ); + + it('Get ChartUri when allowDomainSharding:false', () => { + expect( + getChartDataUri({ + path: '/path', + qs: 'same-string', + allowDomainSharding: false, + }), + ).toEqual({ + _deferred_build: true, + _parts: { + duplicateQueryParameters: false, + escapeQuerySpace: true, + fragment: null, + hostname: 'localhost', + password: null, + path: '/prefix/path', + port: '', + preventInvalidHostname: false, + protocol: 'http', + query: 'same-string', + urn: null, + username: null, + }, + _string: '', + }); + }); + + it('Get ChartUri when allowDomainSharding:true', () => { + expect( + getChartDataUri({ + path: '/path-allowDomainSharding-true', + qs: 'same-string-allowDomainSharding-true', + allowDomainSharding: true, + }), + ).toEqual({ + _deferred_build: true, + _parts: { + duplicateQueryParameters: false, + escapeQuerySpace: true, + fragment: null, + hostname: undefined, + password: null, + path: '/prefix/path-allowDomainSharding-true', + port: '', + preventInvalidHostname: false, + protocol: 'http', + query: 'same-string-allowDomainSharding-true', + urn: null, + username: null, + }, + _string: '', + }); }); }); diff --git a/superset-frontend/src/explore/exploreUtils/index.js b/superset-frontend/src/explore/exploreUtils/index.js index ed4d03c6a24..e26f693626c 100644 --- a/superset-frontend/src/explore/exploreUtils/index.js +++ b/superset-frontend/src/explore/exploreUtils/index.js @@ -30,6 +30,7 @@ import { import { availableDomains } from 'src/utils/hostNamesConfig'; import { safeStringify } from 'src/utils/safeStringify'; import { optionLabel } from 'src/utils/common'; +import { ensureAppRoot } from 'src/utils/pathUtils'; import { URL_PARAMS } from 'src/constants'; import { DISABLE_INPUT_OPERATORS, @@ -69,7 +70,7 @@ export function getAnnotationJsonUrl(slice_id, force) { const uri = URI(window.location.search); return uri - .pathname('/api/v1/chart/data') + .pathname(ensureAppRoot('/api/v1/chart/data')) .search({ form_data: safeStringify({ slice_id }), force, @@ -84,9 +85,9 @@ export function getURIDirectory(endpointType = 'base') { endpointType, ) ) { - return '/superset/explore_json/'; + return ensureAppRoot('/superset/explore_json/'); } - return '/explore/'; + return ensureAppRoot('/explore/'); } export function mountExploreUrl(endpointType, extraSearch = {}, force = false) { @@ -113,7 +114,7 @@ export function getChartDataUri({ path, qs, allowDomainSharding = false }) { protocol: window.location.protocol.slice(0, -1), hostname: getHostName(allowDomainSharding), port: window.location.port ? window.location.port : '', - path, + path: ensureAppRoot(path), }); if (qs) { uri = uri.search(qs); @@ -143,7 +144,10 @@ export function getExploreUrl({ // eslint-disable-next-line no-param-reassign delete formData.label_colors; - let uri = getChartDataUri({ path: '/', allowDomainSharding }); + let uri = getChartDataUri({ + path: '/', + allowDomainSharding, + }); if (curUrl) { uri = URI(URI(curUrl).search()); } @@ -257,7 +261,7 @@ export const exportChart = ({ }); payload = formData; } else { - url = '/api/v1/chart/data'; + url = ensureAppRoot('/api/v1/chart/data'); payload = buildV1ChartDataPayload({ formData, force, diff --git a/superset-frontend/src/features/allEntities/AllEntitiesTable.test.tsx b/superset-frontend/src/features/allEntities/AllEntitiesTable.test.tsx index 5eb6fb341fc..7143c908879 100644 --- a/superset-frontend/src/features/allEntities/AllEntitiesTable.test.tsx +++ b/superset-frontend/src/features/allEntities/AllEntitiesTable.test.tsx @@ -98,6 +98,7 @@ describe('AllEntitiesTable', () => { setShowTagModal={mockSetShowTagModal} objects={mockObjects} />, + { useRouter: true }, ); expect( @@ -114,6 +115,7 @@ describe('AllEntitiesTable', () => { setShowTagModal={mockSetShowTagModal} objects={mockObjectsWithTags} />, + { useRouter: true }, ); expect(screen.getByText('Sales Dashboard')).toBeInTheDocument(); diff --git a/superset-frontend/src/features/charts/ChartCard.tsx b/superset-frontend/src/features/charts/ChartCard.tsx index 15121ba6035..2f038ded144 100644 --- a/superset-frontend/src/features/charts/ChartCard.tsx +++ b/superset-frontend/src/features/charts/ChartCard.tsx @@ -32,6 +32,7 @@ import Chart from 'src/types/Chart'; import { Menu } from 'src/components/Menu'; import { handleChartDelete, CardStyles } from 'src/views/CRUD/utils'; +import { assetUrl } from 'src/utils/assetUrl'; interface ChartCardProps { chart: Chart; @@ -170,7 +171,9 @@ export default function ChartCard({ } url={bulkSelectEnabled ? undefined : chart.url} imgURL={chart.thumbnail_url || ''} - imgFallbackURL="/static/assets/images/chart-card-fallback.svg" + imgFallbackURL={assetUrl( + '/static/assets/images/chart-card-fallback.svg', + )} description={t('Modified %s', chart.changed_on_delta_humanized)} coverLeft={} coverRight={} diff --git a/superset-frontend/src/features/dashboards/DashboardCard.tsx b/superset-frontend/src/features/dashboards/DashboardCard.tsx index 38869ccf1c5..1344ead1455 100644 --- a/superset-frontend/src/features/dashboards/DashboardCard.tsx +++ b/superset-frontend/src/features/dashboards/DashboardCard.tsx @@ -36,6 +36,7 @@ import { import { Menu } from 'src/components/Menu'; import { Icons } from 'src/components/Icons'; import { Dashboard } from 'src/views/CRUD/types'; +import { assetUrl } from 'src/utils/assetUrl'; interface DashboardCardProps { isChart?: boolean; @@ -161,7 +162,9 @@ function DashboardCard({ url={bulkSelectEnabled ? undefined : dashboard.url} linkComponent={Link} imgURL={dashboard.thumbnail_url} - imgFallbackURL="/static/assets/images/dashboard-card-fallback.svg" + imgFallbackURL={assetUrl( + '/static/assets/images/dashboard-card-fallback.svg', + )} description={t('Modified %s', dashboard.changed_on_delta_humanized)} coverLeft={} actions={ diff --git a/superset-frontend/src/features/home/ChartTable.tsx b/superset-frontend/src/features/home/ChartTable.tsx index f38cc856c9d..f63f817f236 100644 --- a/superset-frontend/src/features/home/ChartTable.tsx +++ b/superset-frontend/src/features/home/ChartTable.tsx @@ -44,6 +44,7 @@ import Chart from 'src/types/Chart'; import handleResourceExport from 'src/utils/export'; import { ErrorBoundary, Loading } from 'src/components'; import { Icons } from 'src/components/Icons'; +import { navigateTo } from 'src/utils/navigationUtils'; import EmptyState from './EmptyState'; import { WelcomeTable } from './types'; import SubMenu from './SubMenu'; @@ -198,7 +199,7 @@ function ChartTable({ ), buttonStyle: 'secondary', onClick: () => { - window.location.assign('/chart/add'); + navigateTo('/chart/add', { assign: true }); }, }, { diff --git a/superset-frontend/src/features/home/DashboardTable.tsx b/superset-frontend/src/features/home/DashboardTable.tsx index 6ebf4daa51e..951aa26ff08 100644 --- a/superset-frontend/src/features/home/DashboardTable.tsx +++ b/superset-frontend/src/features/home/DashboardTable.tsx @@ -40,6 +40,7 @@ import { DeleteModal, Loading } from 'src/components'; import PropertiesModal from 'src/dashboard/components/PropertiesModal'; import DashboardCard from 'src/features/dashboards/DashboardCard'; import { Icons } from 'src/components/Icons'; +import { navigateTo } from 'src/utils/navigationUtils'; import EmptyState from './EmptyState'; import SubMenu from './SubMenu'; import { WelcomeTable } from './types'; @@ -197,7 +198,7 @@ function DashboardTable({ ), buttonStyle: 'secondary', onClick: () => { - window.location.assign('/dashboard/new'); + navigateTo('/dashboard/new', { assign: true }); }, }, { diff --git a/superset-frontend/src/features/home/EmptyState.tsx b/superset-frontend/src/features/home/EmptyState.tsx index c4f23782568..dc688fb0aa6 100644 --- a/superset-frontend/src/features/home/EmptyState.tsx +++ b/superset-frontend/src/features/home/EmptyState.tsx @@ -19,6 +19,7 @@ import { Button, EmptyState as EmptyStateComponent } from 'src/components'; import { TableTab } from 'src/views/CRUD/types'; import { styled, t } from '@superset-ui/core'; +import { navigateTo } from 'src/utils/navigationUtils'; import { WelcomeTable } from './types'; const EmptyContainer = styled.div` @@ -83,7 +84,7 @@ export default function EmptyState({
- {% block tail_js %} {% if not standalone_mode %} {{ js_bundle('menu') }} {% - endif %} {% if entry %} {{ js_bundle(entry) }} {% endif %} {% include + {% block tail_js %} {% if not standalone_mode %} {{ js_bundle(assets_prefix, 'menu') }} {% + endif %} {% if entry %} {{ js_bundle(assets_prefix, entry) }} {% endif %} {% include "tail_js_custom_extra.html" %} {% endblock %} diff --git a/superset/templates/superset/partials/asset_bundle.html b/superset/templates/superset/partials/asset_bundle.html index 41b6d7cf8a0..4fe33366d85 100644 --- a/superset/templates/superset/partials/asset_bundle.html +++ b/superset/templates/superset/partials/asset_bundle.html @@ -1,36 +1,37 @@ {# - 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 +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 +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. +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 "superset/macros.html" as macros %} -{% macro js_bundle(filename) %} - {# HTML comment is needed for webpack-dev-server to replace assets - with development version #} - - {% for entry in js_manifest(filename) %} - - {% endfor %} - + +{% macro js_bundle(assets_prefix, filename) %} +{# HTML comment is needed for webpack-dev-server to replace assets +with development version #} + +{% for entry in js_manifest(filename) %} + +{% endfor %} + {% endmacro %} -{% macro css_bundle(filename) %} - - {% for entry in css_manifest(filename) %} - - {% endfor %} - +{% macro css_bundle(assets_prefix, filename) %} + +{% for entry in css_manifest(filename) %} + +{% endfor %} + {% endmacro %} diff --git a/superset/templates/superset/spa.html b/superset/templates/superset/spa.html index 6a0312f4f09..cf284a66be5 100644 --- a/superset/templates/superset/spa.html +++ b/superset/templates/superset/spa.html @@ -22,6 +22,6 @@ {% endblock %} {% block tail_js %} - {{ js_bundle(entry) }} + {{ js_bundle(assets_prefix, entry) }} {% include "tail_js_custom_extra.html" %} {% endblock %} diff --git a/superset/utils/urls.py b/superset/utils/urls.py index 9b186f54f31..08b30dff68f 100644 --- a/superset/utils/urls.py +++ b/superset/utils/urls.py @@ -15,10 +15,11 @@ # specific language governing permissions and limitations # under the License. import urllib +from contextlib import nullcontext from typing import Any from urllib.parse import urlparse -from flask import current_app, url_for +from flask import current_app, has_request_context, url_for def get_url_host(user_friendly: bool = False) -> str: @@ -32,7 +33,12 @@ def headless_url(path: str, user_friendly: bool = False) -> str: def get_url_path(view: str, user_friendly: bool = False, **kwargs: Any) -> str: - with current_app.test_request_context(): + if has_request_context(): + request_context = nullcontext + else: + request_context = current_app.test_request_context + + with request_context(): return headless_url(url_for(view, **kwargs), user_friendly=user_friendly) diff --git a/superset/views/base.py b/superset/views/base.py index 5b7ef99b026..a598994f19f 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -32,6 +32,7 @@ from flask import ( redirect, Response, session, + url_for, ) from flask_appbuilder import BaseView, Model, ModelView from flask_appbuilder.actions import action @@ -256,7 +257,8 @@ def menu_data(user: User) -> dict[str, Any]: return { "menu": appbuilder.menu.get_data(), "brand": { - "path": appbuilder.app.config["LOGO_TARGET_PATH"] or "/superset/welcome/", + "path": appbuilder.app.config["LOGO_TARGET_PATH"] + or url_for("Superset.welcome"), "icon": appbuilder.app_icon, "alt": appbuilder.app_name, "tooltip": appbuilder.app.config["LOGO_TOOLTIP"], @@ -327,6 +329,8 @@ def cached_common_bootstrap_data( # pylint: disable=unused-argument language = locale.language if locale else "en" bootstrap_data = { + "application_root": conf["APPLICATION_ROOT"], + "static_assets_prefix": conf["STATIC_ASSETS_PREFIX"], "conf": frontend_config, "locale": language, "language_pack": get_language_pack(language), diff --git a/superset/views/core.py b/superset/views/core.py index 8a56befe0ba..54dead027b4 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -23,7 +23,7 @@ from datetime import datetime from typing import Any, Callable, cast from urllib import parse -from flask import abort, flash, g, redirect, request, Response +from flask import abort, flash, g, redirect, request, Response, url_for from flask_appbuilder import expose from flask_appbuilder.security.decorators import ( has_access, @@ -132,11 +132,11 @@ class Superset(BaseSupersetView): if not slc: abort(404) form_data = parse.quote(json.dumps({"slice_id": slice_id})) - endpoint = f"/explore/?form_data={form_data}" + endpoint_params = {"form_data": f"{form_data}"} if ReservedUrlParameters.is_standalone_mode(): - endpoint += f"&{ReservedUrlParameters.STANDALONE}=true" - return redirect(endpoint) + endpoint_params[ReservedUrlParameters.STANDALONE] = "true" + return redirect(url_for("ExploreView.root", **endpoint_params)) def get_query_string_response(self, viz_obj: BaseViz) -> FlaskResponse: query = None @@ -421,7 +421,7 @@ class Superset(BaseSupersetView): ) except (ChartNotFoundError, ExplorePermalinkGetFailedError) as ex: flash(__("Error: %(msg)s", msg=ex.message), "danger") - return redirect("/chart/list/") + return redirect(url_for("SliceModelView.list")) elif form_data_key: parameters = CommandParameters(key=form_data_key) value = GetFormDataCommand(parameters).run() @@ -797,7 +797,7 @@ class Superset(BaseSupersetView): redirect_url = f"{appbuilder.get_url_for_login}?next={request.url}" warn_msg = "Users must be logged in to view this dashboard." else: - redirect_url = "/dashboard/list/" + redirect_url = url_for("DashboardModelView.list") warn_msg = utils.error_msg_from_exception(ex) return redirect_with_flash( url=redirect_url, @@ -840,14 +840,16 @@ class Superset(BaseSupersetView): value = GetDashboardPermalinkCommand(key).run() except DashboardPermalinkGetFailedError as ex: flash(__("Error: %(msg)s", msg=ex.message), "danger") - return redirect("/dashboard/list/") + return redirect(url_for("DashboardModelView.list")) except DashboardAccessDeniedError as ex: flash(__("Error: %(msg)s", msg=ex.message), "danger") - return redirect("/dashboard/list/") + return redirect(url_for("DashboardModelView.list")) if not value: return json_error_response(_("permalink state not found"), status=404) dashboard_id, state = value["dashboardId"], value.get("state", {}) - url = f"/superset/dashboard/{dashboard_id}?permalink_key={key}" + url = url_for( + "Superset.dashboard", dashboard_id_or_slug=dashboard_id, permalink_key=key + ) if url_params := state.get("urlParams"): params = parse.urlencode(url_params) url = f"{url}&{params}" @@ -924,4 +926,4 @@ class Superset(BaseSupersetView): @expose("/sqllab/history/", methods=("GET",)) @deprecated(new_target="/sqllab/history") def sqllab_history(self) -> FlaskResponse: - return redirect("/sqllab/history") + return redirect(url_for("SqllabView.history")) diff --git a/superset/views/dashboard/views.py b/superset/views/dashboard/views.py index fb70c7b3963..d5dd27718d3 100644 --- a/superset/views/dashboard/views.py +++ b/superset/views/dashboard/views.py @@ -17,7 +17,7 @@ import builtins from typing import Callable, Union -from flask import g, redirect, Response +from flask import g, redirect, Response, url_for from flask_appbuilder import expose from flask_appbuilder.actions import action from flask_appbuilder.models.sqla.interface import SQLAInterface @@ -66,8 +66,7 @@ class DashboardModelView(DashboardMixin, SupersetModelView, DeleteMixin): # pyl ) -> FlaskResponse: if not isinstance(items, list): items = [items] - ids = "".join(f"&id={d.id}" for d in items) - return redirect(f"/dashboard/export_dashboards_form?{ids[1:]}") + return redirect(url_for("DashboardModelView.download_dashboards", id=items)) class Dashboard(BaseSupersetView): @@ -86,7 +85,11 @@ class Dashboard(BaseSupersetView): ) db.session.add(new_dashboard) db.session.commit() # pylint: disable=consider-using-transaction - return redirect(f"/superset/dashboard/{new_dashboard.id}/?edit=true") + return redirect( + url_for( + "Superset.dashboard", dashboard_id_or_slug=new_dashboard.id, edit="true" + ) + ) @expose("//embedded") @event_logger.log_this_with_extra_payload diff --git a/tests/integration_tests/charts/api_tests.py b/tests/integration_tests/charts/api_tests.py index d4e8f01993e..f776f0e6324 100644 --- a/tests/integration_tests/charts/api_tests.py +++ b/tests/integration_tests/charts/api_tests.py @@ -2132,9 +2132,9 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase): new_tag = db.session.query(Tag).filter(Tag.name == "second_tag").one() # get existing tag and add a new one - new_tags = [tag.id for tag in chart.tags if tag.type == TagType.custom] - new_tags.append(new_tag.id) - update_payload = {"tags": new_tags} + new_tags = {tag.id for tag in chart.tags if tag.type == TagType.custom} + new_tags.add(new_tag.id) + update_payload = {"tags": list(new_tags)} uri = f"api/v1/chart/{chart.id}" rv = self.put_assert_metric(uri, update_payload, "put") @@ -2142,7 +2142,7 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase): model = db.session.query(Slice).get(chart.id) # Clean up system tags - tag_list = [tag.id for tag in model.tags if tag.type == TagType.custom] + tag_list = {tag.id for tag in model.tags if tag.type == TagType.custom} assert tag_list == new_tags @pytest.mark.usefixtures("create_chart_with_tag") @@ -2191,9 +2191,9 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase): new_tag = db.session.query(Tag).filter(Tag.name == "second_tag").one() # get existing tag and add a new one - new_tags = [tag.id for tag in chart.tags if tag.type == TagType.custom] - new_tags.append(new_tag.id) - update_payload = {"tags": new_tags} + new_tags = {tag.id for tag in chart.tags if tag.type == TagType.custom} + new_tags.add(new_tag.id) + update_payload = {"tags": list(new_tags)} uri = f"api/v1/chart/{chart.id}" rv = self.put_assert_metric(uri, update_payload, "put") @@ -2201,7 +2201,7 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase): model = db.session.query(Slice).get(chart.id) # Clean up system tags - tag_list = [tag.id for tag in model.tags if tag.type == TagType.custom] + tag_list = {tag.id for tag in model.tags if tag.type == TagType.custom} assert tag_list == new_tags security_manager.add_permission_role(alpha_role, write_tags_perm) diff --git a/tests/integration_tests/core_tests.py b/tests/integration_tests/core_tests.py index 8fe424b1bdf..84fb7f20dfd 100644 --- a/tests/integration_tests/core_tests.py +++ b/tests/integration_tests/core_tests.py @@ -866,7 +866,7 @@ class TestCore(SupersetTestCase): self.login(ADMIN_USERNAME) resp = self.client.get("superset/dashboard/p/123/") - expected_url = "/superset/dashboard/1?permalink_key=123&standalone=3" + expected_url = "/superset/dashboard/1/?permalink_key=123&standalone=3" assert resp.headers["Location"] == expected_url assert resp.status_code == 302 diff --git a/tests/integration_tests/datasets/api_tests.py b/tests/integration_tests/datasets/api_tests.py index 20642391406..9990110befe 100644 --- a/tests/integration_tests/datasets/api_tests.py +++ b/tests/integration_tests/datasets/api_tests.py @@ -255,7 +255,7 @@ class TestDatasetApi(SupersetTestCase): "table_name", "uuid", ] - assert sorted(list(response["result"][0].keys())) == expected_columns # noqa: C414 + assert sorted(response["result"][0]) == expected_columns def test_get_dataset_list_gamma(self): """ @@ -1563,6 +1563,89 @@ class TestDatasetApi(SupersetTestCase): db.session.delete(dataset) db.session.commit() + @with_feature_flags(DATASET_FOLDERS=True) + def test_update_dataset_add_folders(self): + """ + Dataset API: Test adding folders to dataset + """ + self.login(username="admin") + + dataset = self.insert_default_dataset() + dataset_data = { + "folders": [ + { + "type": "folder", + "uuid": "b49ac3dd-c79b-42a4-9082-39ee74f3b369", + "name": "My metrics", + "children": [ + { + "type": "metric", + "uuid": str(dataset.metrics[0].uuid), + }, + ], + }, + { + "type": "folder", + "uuid": "f5db85fa-75d6-45e5-bdce-c6194db80642", + "name": "My columns", + "children": [ + { + "type": "folder", + "uuid": "b5330233-e323-4157-b767-98b16f00ca93", + "name": "Dimensions", + "children": [ + { + "type": "column", + "uuid": str(dataset.columns[1].uuid), + }, + ], + }, + ], + }, + ] + } + + uri = f"api/v1/dataset/{dataset.id}" + rv = self.put_assert_metric(uri, dataset_data, "put") + print(rv.data.decode("utf-8")) + assert rv.status_code == 200 + + model = db.session.query(SqlaTable).get(dataset.id) + assert model.folders == [ + { + "uuid": "b49ac3dd-c79b-42a4-9082-39ee74f3b369", + "type": "folder", + "name": "My metrics", + "children": [ + { + "uuid": str(dataset.metrics[0].uuid), + "type": "metric", + } + ], + }, + { + "uuid": "f5db85fa-75d6-45e5-bdce-c6194db80642", + "type": "folder", + "name": "My columns", + "children": [ + { + "uuid": "b5330233-e323-4157-b767-98b16f00ca93", + "type": "folder", + "name": "Dimensions", + "children": [ + { + "uuid": str(dataset.columns[1].uuid), + "type": "column", + } + ], + } + ], + }, + ] + + db.session.delete(dataset) + db.session.commit() + def test_delete_dataset_item(self): """ Dataset API: Test delete dataset item @@ -1956,7 +2039,7 @@ class TestDatasetApi(SupersetTestCase): @pytest.mark.usefixtures("create_datasets") def test_export_dataset_gamma(self): """ - Dataset API: Test export dataset has gamma + Dataset API: Test export dataset as gamma """ dataset = self.get_fixture_datasets()[0] @@ -1966,7 +2049,7 @@ class TestDatasetApi(SupersetTestCase): self.login(GAMMA_USERNAME) rv = self.client.get(uri) - assert rv.status_code == 403 + assert rv.status_code in (403, 404) perm1 = security_manager.find_permission_view_menu("can_export", "Dataset") @@ -2016,7 +2099,7 @@ class TestDatasetApi(SupersetTestCase): self.login(ADMIN_USERNAME) rv = self.get_assert_metric(uri, "export") - assert rv.status_code == 404 + assert rv.status_code in (403, 404) @pytest.mark.usefixtures("create_datasets") def test_export_dataset_bundle_gamma(self): @@ -2032,7 +2115,7 @@ class TestDatasetApi(SupersetTestCase): self.login(GAMMA_USERNAME) rv = self.client.get(uri) # gamma users by default do not have access to this dataset - assert rv.status_code == 403 + assert rv.status_code in (403, 404) @unittest.skip("Number of related objects depend on DB") @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") diff --git a/tests/integration_tests/datasets/commands_tests.py b/tests/integration_tests/datasets/commands_tests.py index 2c0a9ce6180..fb802bd1bf8 100644 --- a/tests/integration_tests/datasets/commands_tests.py +++ b/tests/integration_tests/datasets/commands_tests.py @@ -171,6 +171,7 @@ class TestExportDatasetsCommand(SupersetTestCase): "warning_text": None, }, ], + "folders": None, "normalize_columns": False, "always_filter_main_dttm": False, "offset": 0, @@ -235,6 +236,7 @@ class TestExportDatasetsCommand(SupersetTestCase): "extra", "normalize_columns", "always_filter_main_dttm", + "folders", "uuid", "metrics", "columns", diff --git a/tests/unit_tests/commands/databases/sync_permissions_test.py b/tests/unit_tests/commands/databases/sync_permissions_test.py index 10d0723f504..e597e384db9 100644 --- a/tests/unit_tests/commands/databases/sync_permissions_test.py +++ b/tests/unit_tests/commands/databases/sync_permissions_test.py @@ -231,7 +231,7 @@ def test_sync_permissions_command_async_mode_new_db_name( async_task_mock.delay.assert_called_once_with(1, "admin", "Old Name") -def test_resync_permissions_command_get_catalogs(database_with_catalog: MagicMock): +def test_sync_permissions_command_get_catalogs(database_with_catalog: MagicMock): """ Test the ``_get_catalog_names`` method. """ @@ -239,6 +239,23 @@ def test_resync_permissions_command_get_catalogs(database_with_catalog: MagicMoc assert cmmd._get_catalog_names() == ["catalog1", "catalog2"] +def test_sync_permissions_command_get_default_catalog(database_with_catalog: MagicMock): + """ + Test ``_get_catalog_names`` when only the default one should be returned. + + When the database doesn't not support cross-catalog queries (like Postgres), we + should only return all catalogs if multi-catalog is enabled. + """ + database_with_catalog.db_engine_spec.supports_cross_catalog_queries = False + database_with_catalog.allow_multi_catalog = False + cmmd = SyncPermissionsCommand(1, None, db_connection=database_with_catalog) + assert cmmd._get_catalog_names() == {"catalog2"} + + database_with_catalog.allow_multi_catalog = True + cmmd = SyncPermissionsCommand(1, None, db_connection=database_with_catalog) + assert cmmd._get_catalog_names() == ["catalog1", "catalog2"] + + @pytest.mark.parametrize( ("inner_exception, outer_exception"), [ @@ -249,7 +266,7 @@ def test_resync_permissions_command_get_catalogs(database_with_catalog: MagicMoc (GenericDBException, DatabaseConnectionFailedError), ], ) -def test_resync_permissions_command_raise_on_getting_catalogs( +def test_sync_permissions_command_raise_on_getting_catalogs( inner_exception: Exception, outer_exception: Exception, database_with_catalog: MagicMock, @@ -263,7 +280,7 @@ def test_resync_permissions_command_raise_on_getting_catalogs( cmmd._get_catalog_names() -def test_resync_permissions_command_get_schemas(database_with_catalog: MagicMock): +def test_sync_permissions_command_get_schemas(database_with_catalog: MagicMock): """ Test the ``_get_schema_names`` method. """ @@ -282,7 +299,7 @@ def test_resync_permissions_command_get_schemas(database_with_catalog: MagicMock (GenericDBException, DatabaseConnectionFailedError), ], ) -def test_resync_permissions_command_raise_on_getting_schemas( +def test_sync_permissions_command_raise_on_getting_schemas( inner_exception: Exception, outer_exception: Exception, database_with_catalog: MagicMock, @@ -296,7 +313,7 @@ def test_resync_permissions_command_raise_on_getting_schemas( cmmd._get_schema_names("blah") -def test_resync_permissions_command_refresh_schemas( +def test_sync_permissions_command_refresh_schemas( mocker: MockerFixture, database_with_catalog: MagicMock ): """ @@ -319,7 +336,7 @@ def test_resync_permissions_command_refresh_schemas( ) -def test_resync_permissions_command_rename_db_in_perms( +def test_sync_permissions_command_rename_db_in_perms( mocker: MockerFixture, database_with_catalog: MagicMock ): """ diff --git a/tests/unit_tests/commands/dataset/test_update.py b/tests/unit_tests/commands/dataset/test_update.py index 9e99edf2040..fa2026b533b 100644 --- a/tests/unit_tests/commands/dataset/test_update.py +++ b/tests/unit_tests/commands/dataset/test_update.py @@ -14,47 +14,384 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. + +from typing import cast from unittest.mock import MagicMock import pytest +from marshmallow import ValidationError from pytest_mock import MockerFixture from superset import db from superset.commands.dataset.exceptions import DatasetInvalidError -from superset.commands.dataset.update import UpdateDatasetCommand +from superset.commands.dataset.update import UpdateDatasetCommand, validate_folders from superset.connectors.sqla.models import SqlaTable +from superset.datasets.schemas import FolderSchema from superset.models.core import Database +from tests.unit_tests.conftest import with_feature_flags @pytest.mark.usefixture("session") def test_update_uniqueness_error(mocker: MockerFixture) -> None: + """ + Test uniqueness validation in dataset update command. + """ SqlaTable.metadata.create_all(db.session.get_bind()) - database = Database(database_name="my_db", sqlalchemy_uri="sqlite://") - bar = SqlaTable(table_name="bar", schema="foo", database=database) - baz = SqlaTable(table_name="baz", schema="qux", database=database) - db.session.add_all([database, bar, baz]) - db.session.commit() - mock_g = mocker.patch("superset.security.manager.g") - mock_g.user = MagicMock() + # First, make sure session is clean + db.session.rollback() - mocker.patch( - "superset.views.base.security_manager.can_access_all_datasources", - return_value=True, - ) + try: + # Set up test data + database = Database(database_name="my_db", sqlalchemy_uri="sqlite://") + bar = SqlaTable(table_name="bar", schema="foo", database=database) + baz = SqlaTable(table_name="baz", schema="qux", database=database) + db.session.add_all([database, bar, baz]) + db.session.commit() - mocker.patch( - "superset.commands.dataset.update.security_manager.raise_for_ownership", - return_value=None, - ) + # Set up mocks + mock_g = mocker.patch("superset.security.manager.g") + mock_g.user = MagicMock() + mocker.patch( + "superset.views.base.security_manager.can_access_all_datasources", + return_value=True, + ) + mocker.patch( + "superset.commands.dataset.update.security_manager.raise_for_ownership", + return_value=None, + ) + mocker.patch.object(UpdateDatasetCommand, "compute_owners", return_value=[]) - mocker.patch.object(UpdateDatasetCommand, "compute_owners", return_value=[]) + # Run the test that should fail + with pytest.raises(DatasetInvalidError): + UpdateDatasetCommand( + bar.id, + { + "table_name": "baz", + "schema": "qux", + }, + ).run() + except Exception: + db.session.rollback() + raise + finally: + # Clean up - this will run even if the test fails + try: + db.session.query(SqlaTable).filter( + SqlaTable.table_name.in_(["bar", "baz"]), + SqlaTable.schema.in_(["foo", "qux"]), + ).delete(synchronize_session=False) + db.session.query(Database).filter(Database.database_name == "my_db").delete( + synchronize_session=False + ) + db.session.commit() + except Exception: + db.session.rollback() - with pytest.raises(DatasetInvalidError): - UpdateDatasetCommand( - bar.id, + +@with_feature_flags(DATASET_FOLDERS=True) +def test_validate_folders(mocker: MockerFixture) -> None: + """ + Test the folder validation. + """ + metrics = [mocker.MagicMock(metric_name="metric1", uuid="uuid1")] + columns = [ + mocker.MagicMock(column_name="column1", uuid="uuid2"), + mocker.MagicMock(column_name="column2", uuid="uuid3"), + ] + + validate_folders(folders=[], metrics=metrics, columns=columns) + + folders = cast( + list[FolderSchema], + [ { - "table_name": "baz", - "schema": "qux", + "uuid": "uuid4", + "type": "folder", + "name": "My folder", + "children": [ + { + "uuid": "uuid1", + "type": "metric", + "name": "metric1", + }, + { + "uuid": "uuid2", + "type": "column", + "name": "column1", + }, + { + "uuid": "uuid3", + "type": "column", + "name": "column2", + }, + ], }, - ).run() + ], + ) + validate_folders(folders=folders, metrics=metrics, columns=columns) + + +@with_feature_flags(DATASET_FOLDERS=True) +def test_validate_folders_cycle(mocker: MockerFixture) -> None: + """ + Test that we can detect cycles in the folder structure. + """ + folders = cast( + list[FolderSchema], + [ + { + "uuid": "uuid1", + "type": "folder", + "name": "My folder", + "children": [ + { + "uuid": "uuid2", + "type": "folder", + "name": "My other folder", + "children": [ + { + "uuid": "uuid1", + "type": "folder", + "name": "My folder", + "children": [], + }, + ], + }, + ], + }, + ], + ) + + with pytest.raises(ValidationError) as excinfo: + validate_folders(folders=folders, metrics=[], columns=[]) + assert str(excinfo.value) == "Cycle detected: uuid1 appears in its ancestry" + + +@with_feature_flags(DATASET_FOLDERS=True) +def test_validate_folders_inter_cycle(mocker: MockerFixture) -> None: + """ + Test that we can detect cycles between folders. + """ + folders = cast( + list[FolderSchema], + [ + { + "uuid": "uuid1", + "type": "folder", + "name": "My folder", + "children": [ + { + "uuid": "uuid2", + "type": "folder", + "name": "My other folder", + "children": [], + }, + ], + }, + { + "uuid": "uuid2", + "type": "folder", + "name": "My other folder", + "children": [ + { + "uuid": "uuid1", + "type": "folder", + "name": "My folder", + "children": [], + }, + ], + }, + ], + ) + + with pytest.raises(ValidationError) as excinfo: + validate_folders(folders=folders, metrics=[], columns=[]) + assert str(excinfo.value) == "Duplicate UUID in folder structure: uuid2" + + +@with_feature_flags(DATASET_FOLDERS=True) +def test_validate_folders_duplicates(mocker: MockerFixture) -> None: + """ + Test that metrics and columns belong to a single folder. + """ + metrics = [mocker.MagicMock(metric_name="count", uuid="uuid2")] + folders = cast( + list[FolderSchema], + [ + { + "uuid": "uuid1", + "type": "folder", + "name": "My folder", + "children": [ + { + "uuid": "uuid2", + "type": "metric", + "name": "count", + }, + ], + }, + { + "uuid": "uuid2", + "type": "folder", + "name": "My other folder", + "children": [ + { + "uuid": "uuid2", + "type": "metric", + "name": "count", + }, + ], + }, + ], + ) + + with pytest.raises(ValidationError) as excinfo: + validate_folders(folders=folders, metrics=metrics, columns=[]) + assert str(excinfo.value) == "Duplicate UUID in folder structure: uuid2" + + +@with_feature_flags(DATASET_FOLDERS=True) +def test_validate_folders_duplicate_name_not_siblings(mocker: MockerFixture) -> None: + """ + Duplicate folder names are allowed if folders are not siblings. + """ + folders = cast( + list[FolderSchema], + [ + { + "uuid": "uuid1", + "type": "folder", + "name": "Sales", + "children": [ + { + "uuid": "uuid2", + "type": "folder", + "name": "Core", + "children": [], + }, + ], + }, + { + "uuid": "uuid3", + "type": "folder", + "name": "Engineering", + "children": [ + { + "uuid": "uuid4", + "type": "folder", + "name": "Core", + "children": [], + }, + ], + }, + ], + ) + + validate_folders(folders=folders, metrics=[], columns=[]) + + +@with_feature_flags(DATASET_FOLDERS=True) +def test_validate_folders_duplicate_name_siblings(mocker: MockerFixture) -> None: + """ + Duplicate folder names are not allowed if folders are siblings. + """ + folders = cast( + list[FolderSchema], + [ + { + "uuid": "uuid1", + "type": "folder", + "name": "Sales", + "children": [ + { + "uuid": "uuid2", + "type": "folder", + "name": "Core", + "children": [], + }, + ], + }, + { + "uuid": "uuid3", + "type": "folder", + "name": "Sales", + "children": [ + { + "uuid": "uuid4", + "type": "folder", + "name": "Other", + "children": [], + }, + ], + }, + ], + ) + + with pytest.raises(ValidationError) as excinfo: + validate_folders(folders=folders, metrics=[], columns=[]) + assert str(excinfo.value) == "Duplicate folder name: Sales" + + +@with_feature_flags(DATASET_FOLDERS=True) +def test_validate_folders_invalid_names(mocker: MockerFixture) -> None: + """ + Test that we can detect reserved folder names. + """ + folders_with_metrics = cast( + list[FolderSchema], + [ + { + "uuid": "uuid1", + "type": "folder", + "name": "Metrics", + "children": [], + }, + ], + ) + folders_with_columns = cast( + list[FolderSchema], + [ + { + "uuid": "uuid1", + "type": "folder", + "name": "Columns", + "children": [], + }, + ], + ) + + with pytest.raises(ValidationError) as excinfo: + validate_folders(folders=folders_with_metrics, metrics=[], columns=[]) + assert str(excinfo.value) == "Folder cannot have name 'Metrics'" + + with pytest.raises(ValidationError) as excinfo: + validate_folders(folders=folders_with_columns, metrics=[], columns=[]) + assert str(excinfo.value) == "Folder cannot have name 'Columns'" + + +@with_feature_flags(DATASET_FOLDERS=True) +def test_validate_folders_invalid_uuid(mocker: MockerFixture) -> None: + """ + Test that we can detect invalid UUIDs. + """ + folders = cast( + list[FolderSchema], + [ + { + "uuid": "uuid4", + "type": "folder", + "name": "My folder", + "children": [ + { + "uuid": "uuid2", + "type": "metric", + "name": "metric1", + }, + ], + }, + ], + ) + + with pytest.raises(ValidationError): + FolderSchema(many=True).load(folders) diff --git a/tests/unit_tests/datasets/commands/export_test.py b/tests/unit_tests/datasets/commands/export_test.py index f42bbfc9cbb..04bc989ba5b 100644 --- a/tests/unit_tests/datasets/commands/export_test.py +++ b/tests/unit_tests/datasets/commands/export_test.py @@ -16,6 +16,8 @@ # under the License. # pylint: disable=import-outside-toplevel, unused-argument, unused-import +from uuid import UUID + from sqlalchemy.orm.session import Session from superset import db @@ -47,6 +49,7 @@ def test_export(session: Session) -> None: type="INTEGER", expression="revenue-expenses", extra=json.dumps({"certified_by": "User"}), + uuid=UUID("00000000-0000-0000-0000-000000000005"), ), ] metrics = [ @@ -54,6 +57,7 @@ def test_export(session: Session) -> None: metric_name="cnt", expression="COUNT(*)", extra=json.dumps({"warning_markdown": None}), + uuid=UUID("00000000-0000-0000-0000-000000000004"), ), ] @@ -61,6 +65,46 @@ def test_export(session: Session) -> None: table_name="my_table", columns=columns, metrics=metrics, + folders=[ + { + "uuid": "00000000-0000-0000-0000-000000000000", + "type": "folder", + "name": "Engineering", + "children": [ + { + "uuid": "00000000-0000-0000-0000-000000000001", + "type": "folder", + "name": "Core", + "children": [ + { + "uuid": "00000000-0000-0000-0000-000000000004", + "type": "metric", + "name": "cnt", + }, + ], + }, + ], + }, + { + "uuid": "00000000-0000-0000-0000-000000000002", + "type": "folder", + "name": "Sales", + "children": [ + { + "uuid": "00000000-0000-0000-0000-000000000003", + "type": "folder", + "name": "Core", + "children": [ + { + "uuid": "00000000-0000-0000-0000-000000000005", + "type": "column", + "name": "profit", + }, + ], + }, + ], + }, + ], main_dttm_col="ds", database=database, offset=-8, @@ -126,6 +170,29 @@ extra: warning_markdown: '*WARNING*' normalize_columns: false always_filter_main_dttm: false +folders: +- uuid: 00000000-0000-0000-0000-000000000000 + type: folder + name: Engineering + children: + - uuid: 00000000-0000-0000-0000-000000000001 + type: folder + name: Core + children: + - uuid: 00000000-0000-0000-0000-000000000004 + type: metric + name: cnt +- uuid: 00000000-0000-0000-0000-000000000002 + type: folder + name: Sales + children: + - uuid: 00000000-0000-0000-0000-000000000003 + type: folder + name: Core + children: + - uuid: 00000000-0000-0000-0000-000000000005 + type: column + name: profit uuid: {payload["uuid"]} metrics: - metric_name: cnt diff --git a/tests/unit_tests/datasets/commands/importers/v1/import_test.py b/tests/unit_tests/datasets/commands/importers/v1/import_test.py index 6bba6f039d5..2d8c0ede4d6 100644 --- a/tests/unit_tests/datasets/commands/importers/v1/import_test.py +++ b/tests/unit_tests/datasets/commands/importers/v1/import_test.py @@ -89,6 +89,7 @@ def test_import_dataset(mocker: MockerFixture, session: Session) -> None: "d3format": None, "extra": {"warning_markdown": None}, "warning_text": None, + "uuid": "00000000-0000-0000-0000-000000000001", } ], "columns": [ @@ -106,8 +107,49 @@ def test_import_dataset(mocker: MockerFixture, session: Session) -> None: "extra": { "certified_by": "User", }, + "uuid": "00000000-0000-0000-0000-000000000002", } ], + "folders": [ + { + "uuid": "00000000-0000-0000-0000-000000000000", + "type": "folder", + "name": "Engineering", + "children": [ + { + "uuid": "00000000-0000-0000-0000-000000000001", + "type": "folder", + "name": "Core", + "children": [ + { + "uuid": "00000000-0000-0000-0000-000000000004", + "type": "metric", + "name": "cnt", + }, + ], + }, + ], + }, + { + "uuid": "00000000-0000-0000-0000-000000000002", + "type": "folder", + "name": "Sales", + "children": [ + { + "uuid": "00000000-0000-0000-0000-000000000003", + "type": "folder", + "name": "Core", + "children": [ + { + "uuid": "00000000-0000-0000-0000-000000000005", + "type": "column", + "name": "profit", + }, + ], + }, + ], + }, + ], "database_uuid": database.uuid, "database_id": database.id, } @@ -139,6 +181,9 @@ def test_import_dataset(mocker: MockerFixture, session: Session) -> None: assert sqla_table.metrics[0].d3format is None assert sqla_table.metrics[0].extra == '{"warning_markdown": null}' assert sqla_table.metrics[0].warning_text is None + assert sqla_table.metrics[0].uuid == uuid.UUID( + "00000000-0000-0000-0000-000000000001" + ) assert len(sqla_table.columns) == 1 assert sqla_table.columns[0].column_name == "profit" assert sqla_table.columns[0].verbose_name is None @@ -151,10 +196,131 @@ def test_import_dataset(mocker: MockerFixture, session: Session) -> None: assert sqla_table.columns[0].description is None assert sqla_table.columns[0].python_date_format is None assert sqla_table.columns[0].extra == '{"certified_by": "User"}' + assert sqla_table.columns[0].uuid == uuid.UUID( + "00000000-0000-0000-0000-000000000002" + ) + assert sqla_table.folders == [ + { + "uuid": "00000000-0000-0000-0000-000000000000", + "type": "folder", + "name": "Engineering", + "children": [ + { + "uuid": "00000000-0000-0000-0000-000000000001", + "type": "folder", + "name": "Core", + "children": [ + { + "uuid": "00000000-0000-0000-0000-000000000004", + "type": "metric", + "name": "cnt", + }, + ], + }, + ], + }, + { + "uuid": "00000000-0000-0000-0000-000000000002", + "type": "folder", + "name": "Sales", + "children": [ + { + "uuid": "00000000-0000-0000-0000-000000000003", + "type": "folder", + "name": "Core", + "children": [ + { + "uuid": "00000000-0000-0000-0000-000000000005", + "type": "column", + "name": "profit", + }, + ], + }, + ], + }, + ] assert sqla_table.database.uuid == database.uuid assert sqla_table.database.id == database.id +def test_import_dataset_no_folder(mocker: MockerFixture, session: Session) -> None: + """ + Test importing a dataset that was exported without folders. + """ + from superset import security_manager + from superset.commands.dataset.importers.v1.utils import import_dataset + from superset.connectors.sqla.models import SqlaTable + from superset.models.core import Database + + mocker.patch.object(security_manager, "can_access", return_value=True) + + engine = db.session.get_bind() + SqlaTable.metadata.create_all(engine) # pylint: disable=no-member + + database = Database(database_name="my_database", sqlalchemy_uri="sqlite://") + db.session.add(database) + db.session.flush() + + dataset_uuid = uuid.uuid4() + config = { + "table_name": "my_table", + "main_dttm_col": "ds", + "description": "This is the description", + "default_endpoint": None, + "offset": -8, + "cache_timeout": 3600, + "catalog": "public", + "schema": "my_schema", + "sql": None, + "params": { + "remote_id": 64, + "database_name": "examples", + "import_time": 1606677834, + }, + "template_params": { + "answer": "42", + }, + "filter_select_enabled": True, + "fetch_values_predicate": "foo IN (1, 2)", + "extra": {"warning_markdown": "*WARNING*"}, + "uuid": dataset_uuid, + "metrics": [ + { + "metric_name": "cnt", + "verbose_name": None, + "metric_type": None, + "expression": "COUNT(*)", + "description": None, + "d3format": None, + "extra": {"warning_markdown": None}, + "warning_text": None, + } + ], + "columns": [ + { + "column_name": "profit", + "verbose_name": None, + "is_dttm": None, + "is_active": None, + "type": "INTEGER", + "groupby": None, + "filterable": None, + "expression": "revenue-expenses", + "description": None, + "python_date_format": None, + "extra": { + "certified_by": "User", + }, + } + ], + "database_uuid": database.uuid, + "database_id": database.id, + } + + sqla_table = import_dataset(config) + assert sqla_table.folders is None + + def test_import_dataset_duplicate_column( mocker: MockerFixture, session: Session ) -> None: