Compare commits

...
Author SHA1 Message Date
Maxime BeaucheminandClaude 59ddd52789 fix(sqllab): Set explicit Content-Type headers to prevent HTTP 406 errors
Fixes #36072 where SQL Lab queries with WHERE clauses failed with
"Database error: Not acceptable" in Superset v4.1+.

Root cause: Flask 2.3+ (upgraded in v4.1.0) has stricter content
negotiation that could return HTTP 406 when Content-Type headers
aren't explicitly set, particularly with ENABLE_PROXY_FIX or certain
Accept header configurations.

Changes:
- Add explicit Content-Type headers to /api/v1/sqllab/execute/ and
  /api/v1/sqllab/results/ endpoints
- Improve error handling with try-except blocks for result fetching
  and JSON serialization
- Add targeted integration test for WHERE clause queries

The fix ensures Flask 2.3+ doesn't attempt content negotiation that
could fail, while maintaining backward compatibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 10:47:44 -08:00
Antonio Rivero 60f29ba6fb chore(logs): Add is_cached in sync AND async results (#36102) 2025-11-13 16:04:49 +01:00
Mehmet Salih Yavuz 306f4c14cf fix(sql): quote column names with spaces to prevent SQLGlot parsing errors (#35553) 2025-11-13 17:47:16 +03:00
Juan Manuel Cárdenas 310dcd7b94 chore: 🌐 Translations added to ES .po file (#30759) 2025-11-13 08:53:12 +03:00
Joe LiandClaude 008c7c6517 fix(table-chart): fix missing table header IDs (#35968)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-12 13:15:55 -08:00
Amin Ghadersohi c244e7f847 fix(mcp): simplify health_check tool and refactor system utils (#36063) 2025-11-12 10:28:09 -08:00
Levis Mbote bb2e2a5ed6 fix: fix tabs overflow in dashboards (#35984) 2025-11-12 19:35:29 +03:00
Richard Fogaca NienkotterandMehmet Salih Yavuz a45c0528da fix(dashboard): dashboard filter was incorrectly showing as out of scope (#35886)
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
2025-11-12 17:24:04 +01:00
0b535b792e feat(frontend): add dataset cache clearing utilities and integration (#35264)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Geidō <60598000+geido@users.noreply.github.com>
2025-11-12 17:23:37 +03:00
Richard Fogaca Nienkotter 9fbfcf0ccd fix(sqllab): prevent unwanted tab switching when autocompleting table names on SQL Lab (#35992) 2025-11-12 10:48:48 +01:00
Richard Fogaca Nienkotter d123249bd2 fix(dashboard): align filter bar elements vertically in horizontal mode (#36036) 2025-11-11 11:48:25 +03:00
Richard Fogaca Nienkotter 4376476ec4 fix: saved query preview modal not highlighting active rows (#35866) 2025-11-11 11:15:27 +03:00
Richard Fogaca Nienkotter 9be61a1245 fix(dashboard): prevent validation error in properties modal when ope… (#36045) 2025-11-11 11:09:07 +03:00
Mehmet Salih Yavuz e2e831e322 fix(permalink): exclude edit mode from dashboard permalink (#35889) 2025-11-11 10:58:02 +03:00
Joe LiandClaude 21d585d586 fix(explore): show validation errors in View Query modal (#35969)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-10 14:47:52 -08:00
Joe LiandClaude 0a5144fc1d fix(tests): fix flakey tests with PropertiesModal.test.tsx, FiltersConfigModal.test.tsx and ChartList.listview.test.tsx (#36037)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-10 14:31:48 -08:00
Joe LiandClaude 64ca080bb8 feat(playwright): Remove Cypress auth tests in favor of Playwright auth tests (#35938)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-10 14:29:01 -08:00
b85621e9a7 docs: Add custom chart plugin deployment instructions (#36028)
Co-authored-by: Daniel Garcia Briseno <daniel.garciabriseno@nasa.gov>
Co-authored-by: Joe Li <joe@preset.io>
2025-11-10 12:19:51 -08:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e915d7d1d0 chore(deps): bump min-document from 2.19.0 to 2.19.1 in /superset-frontend (#36046)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-10 12:18:36 -08:00
Richard Fogaca Nienkotter ae63f64771 style(sqllab): restore Template Parameters modal styling (#35965) 2025-11-10 18:51:12 +03:00
Kamil Gabryjelski 63dfd95aa2 fix: Flakiness around scrolling during taking tiled screenshots with Playwright (#36051) 2025-11-10 12:57:28 +01:00
Mehmet Salih Yavuz c9f65cf1c2 fix(date_parser): add check for time range timeshifts (#36039) 2025-11-10 13:46:03 +03:00
Elizabeth ThompsonandClaude c42e3c6837 fix(reports): improve error handling for report schedule execution (#35800)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-07 17:57:43 -08:00
Amin Ghadersohi 3167a0dbc0 chore(mcp-service): upgrade fastmcp from 2.10.6 to 2.13.0.2 (#36014) 2025-11-07 17:52:03 -08:00
Elizabeth ThompsonandClaude 909bd877c9 fix(filters): preserve backend metric-based sorting (#35152)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-07 11:40:56 -08:00
Mehmet Salih Yavuz 4d0fdba97a fix(webpack): webpack refresh plugin (#36041) 2025-11-07 10:26:08 -08:00
Amin Ghadersohi d2550a525b refactor(mcp): shorten tool name from get_superset_instance_info to get_instance_info (#36032) 2025-11-07 10:19:32 -08:00
86 changed files with 4867 additions and 1668 deletions
+1
View File
@@ -83,6 +83,7 @@ github:
- cypress-matrix (5, chrome)
- dependency-review
- frontend-build
- playwright-tests (chromium)
- pre-commit (current)
- pre-commit (previous)
- test-mysql
+21 -2
View File
@@ -195,6 +195,7 @@ playwright-install() {
playwright-run() {
local APP_ROOT=$1
local TEST_PATH=$2
# Start Flask from the project root (same as Cypress)
cd "$GITHUB_WORKSPACE"
@@ -238,8 +239,26 @@ playwright-run() {
say "::group::Run Playwright tests"
echo "Running Playwright with baseURL: ${PLAYWRIGHT_BASE_URL}"
npx playwright test auth/login --reporter=github --output=playwright-results
local status=$?
if [ -n "$TEST_PATH" ]; then
# Check if there are any test files in the specified path
if ! find "playwright/tests/${TEST_PATH}" -name "*.spec.ts" -type f 2>/dev/null | grep -q .; then
echo "No test files found in ${TEST_PATH} - skipping test run"
say "::endgroup::"
kill $flaskProcessId
return 0
fi
echo "Running tests: ${TEST_PATH}"
# Set INCLUDE_EXPERIMENTAL=true to allow experimental tests to run
export INCLUDE_EXPERIMENTAL=true
npx playwright test "${TEST_PATH}" --output=playwright-results
local status=$?
# Unset to prevent leaking into subsequent commands
unset INCLUDE_EXPERIMENTAL
else
echo "Running all required tests (experimental/ excluded via playwright.config.ts)"
npx playwright test --output=playwright-results
local status=$?
fi
say "::endgroup::"
# After job is done, print out Flask log for debugging
+115
View File
@@ -151,3 +151,118 @@ jobs:
with:
path: ${{ github.workspace }}/superset-frontend/cypress-base/cypress/screenshots
name: cypress-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}-${{ matrix.parallel_id }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
playwright-tests:
runs-on: ubuntu-22.04
permissions:
contents: read
pull-requests: read
strategy:
fail-fast: false
matrix:
browser: ["chromium"]
app_root: ["", "/app/prefix"]
env:
SUPERSET_ENV: development
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
PYTHONPATH: ${{ github.workspace }}
REDIS_PORT: 16379
GITHUB_TOKEN: ${{ github.token }}
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
ports:
- 15432:5432
redis:
image: redis:7-alpine
ports:
- 16379:6379
steps:
# -------------------------------------------------------
# Conditional checkout based on context (same as Cypress workflow)
- name: Checkout for push or pull_request event
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@v5
with:
persist-credentials: false
submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Checkout using ref (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
uses: actions/checkout@v5
with:
persist-credentials: false
ref: ${{ github.event.inputs.ref }}
submodules: recursive
- name: Checkout using PR ID (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
uses: actions/checkout@v5
with:
persist-credentials: false
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
submodules: recursive
# -------------------------------------------------------
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
uses: ./.github/actions/setup-backend/
if: steps.check.outputs.python || steps.check.outputs.frontend
- name: Setup postgres
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: setup-postgres
- name: Import test data
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v5
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install npm dependencies
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: npm-install
- name: Build javascript packages
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: build-instrumented-assets
- name: Install Playwright
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: playwright-install
- name: Run Playwright (Required Tests)
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
env:
NODE_OPTIONS: "--max-old-space-size=4096"
with:
run: playwright-run "${{ matrix.app_root }}"
- name: Set safe app root
if: failure()
id: set-safe-app-root
run: |
APP_ROOT="${{ matrix.app_root }}"
SAFE_APP_ROOT=${APP_ROOT//\//_}
echo "safe_app_root=$SAFE_APP_ROOT" >> $GITHUB_OUTPUT
- name: Upload Playwright Artifacts
uses: actions/upload-artifact@v4
if: failure()
with:
path: |
${{ github.workspace }}/superset-frontend/playwright-results/
${{ github.workspace }}/superset-frontend/test-results/
name: playwright-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
+7 -6
View File
@@ -1,4 +1,4 @@
name: Playwright E2E Tests
name: Playwright Experimental Tests
on:
push:
@@ -23,9 +23,10 @@ concurrency:
cancel-in-progress: true
jobs:
playwright-tests:
# NOTE: Required Playwright tests are in superset-e2e.yml (E2E / playwright-tests)
# This workflow contains only experimental tests that run in shadow mode
playwright-tests-experimental:
runs-on: ubuntu-22.04
# Allow workflow to succeed even if tests fail during shadow mode
continue-on-error: true
permissions:
contents: read
@@ -117,13 +118,13 @@ jobs:
uses: ./.github/actions/cached-dependencies
with:
run: playwright-install
- name: Run Playwright
- name: Run Playwright (Experimental Tests)
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
env:
NODE_OPTIONS: "--max-old-space-size=4096"
with:
run: playwright-run ${{ matrix.app_root }}
run: playwright-run "${{ matrix.app_root }}" experimental/
- name: Set safe app root
if: failure()
id: set-safe-app-root
@@ -138,4 +139,4 @@ jobs:
path: |
${{ github.workspace }}/superset-frontend/playwright-results/
${{ github.workspace }}/superset-frontend/test-results/
name: playwright-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
name: playwright-experimental-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
+50
View File
@@ -166,6 +166,56 @@ server:
npm run dev-server
```
#### Deploying your visualization plugin
Once your plugin is complete, you will need to deploy it to your superset instance.
This step assumes you are running your own Docker image as described [here](https://superset.apache.org/docs/installation/docker-builds/#building-your-own-production-docker-image).
Instructions may vary for other kinds of deployments.
If you have your own Superset Docker image, the first line is most likely:
`FROM apache/superset:latest` or something similar. You will need to compile
your own `"lean"` image and replace this FROM line with your own image.
1. Publish your chart plugin to npm: it makes the build process simpler.
Note: if your chart is not published to npm, then in the docker build below, you will need
to edit the default Dockerfile to copy your plugin source code to the appropriate
location in the container build environment.
2. Install your chart in the frontend with `npm i <your_chart_package>`.
3. Start with a base superset release.
```bash
git checkout tags/X.0.0
```
4. Install your chart with the instructions you followed during development.
5. Navigate to the root of your superset directory.
6. Run `docker build -t apache/superset:mychart --target lean .`
7. Rebuild your production container using `FROM apache/superset:mychart`.
This will create a new productized superset container with your new chart compiled in.
Then you can recreate your custom production container based on a superset built with your chart.
##### Troubleshooting
- If you get the following NPM error:
```
npm error `npm ci` can only install packages when your package.json and package-lock.json
```
It's because your local nodejs/npm version is different than the one being used inside docker.
You can resolve this by running npm install with the same version used by the container build process
Replace XYZ in the following command with the node tag used in the Dockerfile (search for "node:" in the Dockerfile to find the tag).
```bash
docker run --rm -v $PWD/superset-frontend:/app node:XYZ /bin/bash -c "cd /app && npm i"
```
## Testing
### Python Testing
+1 -1
View File
@@ -140,7 +140,7 @@ solr = ["sqlalchemy-solr >= 0.2.0"]
elasticsearch = ["elasticsearch-dbapi>=0.2.9, <0.3.0"]
exasol = ["sqlalchemy-exasol >= 2.4.0, <3.0"]
excel = ["xlrd>=1.2.0, <1.3"]
fastmcp = ["fastmcp>=2.10.6"]
fastmcp = ["fastmcp>=2.13.0.2"]
firebird = ["sqlalchemy-firebird>=0.7.0, <0.8"]
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
gevent = ["gevent>=23.9.1"]
+4 -3
View File
@@ -42,7 +42,7 @@ cachelib==0.13.0
# via
# flask-caching
# flask-session
cachetools==5.5.2
cachetools==6.2.1
# via google-auth
cattrs==25.1.1
# via requests-cache
@@ -154,12 +154,13 @@ geographiclib==2.0
# via geopy
geopy==2.4.1
# via apache-superset (pyproject.toml)
google-auth==2.40.3
google-auth==2.43.0
# via shillelagh
greenlet==3.1.1
# via
# apache-superset (pyproject.toml)
# shillelagh
# sqlalchemy
gunicorn==23.0.0
# via apache-superset (pyproject.toml)
h11==0.16.0
@@ -404,7 +405,7 @@ trio==0.30.0
# trio-websocket
trio-websocket==0.12.2
# via selenium
typing-extensions==4.14.0
typing-extensions==4.15.0
# via
# apache-superset (pyproject.toml)
# alembic
+53 -6
View File
@@ -58,10 +58,16 @@ backoff==2.2.1
# via
# -c requirements/base-constraint.txt
# apache-superset
backports-tarfile==1.2.0
# via jaraco-context
bcrypt==4.3.0
# via
# -c requirements/base-constraint.txt
# paramiko
beartype==0.22.5
# via
# py-key-value-aio
# py-key-value-shared
billiard==4.2.1
# via
# -c requirements/base-constraint.txt
@@ -83,10 +89,11 @@ cachelib==0.13.0
# -c requirements/base-constraint.txt
# flask-caching
# flask-session
cachetools==5.5.2
cachetools==6.2.1
# via
# -c requirements/base-constraint.txt
# google-auth
# py-key-value-aio
cattrs==25.1.1
# via
# -c requirements/base-constraint.txt
@@ -167,7 +174,9 @@ cryptography==44.0.3
# apache-superset
# authlib
# paramiko
# pyjwt
# pyopenssl
# secretstorage
cycler==0.12.1
# via matplotlib
cyclopts==3.24.0
@@ -188,6 +197,8 @@ deprecation==2.1.0
# apache-superset
dill==0.4.0
# via pylint
diskcache==5.6.3
# via py-key-value-aio
distlib==0.3.8
# via virtualenv
dnspython==2.7.0
@@ -217,7 +228,7 @@ et-xmlfile==2.0.0
# openpyxl
exceptiongroup==1.3.0
# via fastmcp
fastmcp==2.10.6
fastmcp==2.13.0.2
# via apache-superset
filelock==3.12.2
# via virtualenv
@@ -319,7 +330,7 @@ google-api-core==2.23.0
# google-cloud-core
# pandas-gbq
# sqlalchemy-bigquery
google-auth==2.40.3
google-auth==2.43.0
# via
# -c requirements/base-constraint.txt
# google-api-core
@@ -357,6 +368,7 @@ greenlet==3.1.1
# apache-superset
# gevent
# shillelagh
# sqlalchemy
grpcio==1.71.0
# via
# apache-superset
@@ -406,6 +418,8 @@ idna==3.10
# requests
# trio
# url-normalize
importlib-metadata==8.7.0
# via keyring
importlib-resources==6.5.2
# via prophet
iniconfig==2.0.0
@@ -421,6 +435,16 @@ itsdangerous==2.2.0
# -c requirements/base-constraint.txt
# flask
# flask-wtf
jaraco-classes==3.4.0
# via keyring
jaraco-context==6.0.1
# via keyring
jaraco-functools==4.3.0
# via keyring
jeepney==0.9.0
# via
# keyring
# secretstorage
jinja2==3.1.6
# via
# -c requirements/base-constraint.txt
@@ -439,12 +463,16 @@ jsonschema==4.23.0
# openapi-schema-validator
# openapi-spec-validator
jsonschema-path==0.3.4
# via openapi-spec-validator
# via
# fastmcp
# openapi-spec-validator
jsonschema-specifications==2025.4.1
# via
# -c requirements/base-constraint.txt
# jsonschema
# openapi-schema-validator
keyring==25.6.0
# via py-key-value-aio
kiwisolver==1.4.7
# via matplotlib
kombu==5.5.3
@@ -496,12 +524,16 @@ matplotlib==3.9.0
# via prophet
mccabe==0.7.0
# via pylint
mcp==1.14.1
mcp==1.20.0
# via fastmcp
mdurl==0.1.2
# via
# -c requirements/base-constraint.txt
# markdown-it-py
more-itertools==10.8.0
# via
# jaraco-classes
# jaraco-functools
msgpack==1.0.8
# via
# -c requirements/base-constraint.txt
@@ -598,6 +630,8 @@ parsedatetime==2.6
# apache-superset
pathable==0.4.3
# via jsonschema-path
pathvalidate==3.3.1
# via py-key-value-aio
pgsanity==0.2.9
# via
# -c requirements/base-constraint.txt
@@ -612,6 +646,7 @@ pip==25.1.1
platformdirs==4.3.8
# via
# -c requirements/base-constraint.txt
# fastmcp
# pylint
# requests-cache
# virtualenv
@@ -654,6 +689,10 @@ psutil==6.1.0
# via apache-superset
psycopg2-binary==2.9.6
# via apache-superset
py-key-value-aio==0.2.8
# via fastmcp
py-key-value-shared==0.2.8
# via py-key-value-aio
pyarrow==16.1.0
# via
# -c requirements/base-constraint.txt
@@ -709,6 +748,7 @@ pyjwt==2.10.1
# apache-superset
# flask-appbuilder
# flask-jwt-extended
# mcp
pylint==3.3.7
# via apache-superset
pynacl==1.5.0
@@ -844,6 +884,8 @@ rsa==4.9.1
# google-auth
ruff==0.8.0
# via apache-superset
secretstorage==3.4.0
# via keyring
selenium==4.32.0
# via
# -c requirements/base-constraint.txt
@@ -941,7 +983,7 @@ trio-websocket==0.12.2
# via
# -c requirements/base-constraint.txt
# selenium
typing-extensions==4.14.0
typing-extensions==4.15.0
# via
# -c requirements/base-constraint.txt
# alembic
@@ -950,6 +992,7 @@ typing-extensions==4.14.0
# cattrs
# exceptiongroup
# limits
# py-key-value-shared
# pydantic
# pydantic-core
# pyopenssl
@@ -1004,6 +1047,8 @@ websocket-client==1.8.0
# via
# -c requirements/base-constraint.txt
# selenium
websockets==15.0.1
# via fastmcp
werkzeug==3.1.3
# via
# -c requirements/base-constraint.txt
@@ -1039,6 +1084,8 @@ xlsxwriter==3.0.9
# -c requirements/base-constraint.txt
# apache-superset
# pandas
zipp==3.23.0
# via importlib-metadata
zope-event==5.0
# via gevent
zope-interface==5.4.0
@@ -1,49 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { LOGIN } from 'cypress/utils/urls';
function interceptLogin() {
cy.intercept('POST', '**/login/').as('login');
}
describe('Login view', () => {
beforeEach(() => {
cy.visit(LOGIN);
});
it('should redirect to login with incorrect username and password', () => {
interceptLogin();
cy.getBySel('login-form').should('be.visible');
cy.getBySel('username-input').type('admin');
cy.getBySel('password-input').type('wrongpassword');
cy.getBySel('login-button').click();
cy.wait('@login');
cy.url().should('include', LOGIN);
});
it('should login with correct username and password', () => {
interceptLogin();
cy.getBySel('login-form').should('be.visible');
cy.getBySel('username-input').type('admin');
cy.getBySel('password-input').type('general');
cy.getBySel('login-button').click();
cy.wait('@login');
cy.getCookies().should('have.length', 1);
});
});
+130 -16
View File
@@ -162,6 +162,7 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.56.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.17",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
"@storybook/addon-essentials": "8.1.11",
@@ -257,6 +258,7 @@
"prettier": "3.6.2",
"prettier-plugin-packagejson": "^2.5.19",
"process": "^0.11.10",
"react-refresh": "^0.14.2",
"react-resizable": "^3.0.5",
"redux-mock-store": "^1.5.4",
"sinon": "^18.0.0",
@@ -5329,6 +5331,7 @@
"resolved": "https://registry.npmjs.org/@fontsource/fira-code/-/fira-code-5.2.7.tgz",
"integrity": "sha512-tnB9NNund9TwIym8/7DMJe573nlPEQb+fKUV5GL8TBYXjIhDvL0D7mgmNVNQUPhXp+R7RylQeiBdkA4EbOHPGQ==",
"license": "OFL-1.1",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
@@ -10744,6 +10747,55 @@
"node": ">=18"
}
},
"node_modules/@pmmmwh/react-refresh-webpack-plugin": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz",
"integrity": "sha512-tXDyE1/jzFsHXjhRZQ3hMl0IVhYe5qula43LDWIhVfjp9G/nT5OQY5AORVOrkEGAUltBJOfOWeETbmhm6kHhuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-html": "^0.0.9",
"core-js-pure": "^3.23.3",
"error-stack-parser": "^2.0.6",
"html-entities": "^2.1.0",
"loader-utils": "^2.0.4",
"schema-utils": "^4.2.0",
"source-map": "^0.7.3"
},
"engines": {
"node": ">= 10.13"
},
"peerDependencies": {
"@types/webpack": "4.x || 5.x",
"react-refresh": ">=0.10.0 <1.0.0",
"sockjs-client": "^1.4.0",
"type-fest": ">=0.17.0 <5.0.0",
"webpack": ">=4.43.0 <6.0.0",
"webpack-dev-server": "3.x || 4.x || 5.x",
"webpack-hot-middleware": "2.x",
"webpack-plugin-serve": "0.x || 1.x"
},
"peerDependenciesMeta": {
"@types/webpack": {
"optional": true
},
"sockjs-client": {
"optional": true
},
"type-fest": {
"optional": true
},
"webpack-dev-server": {
"optional": true
},
"webpack-hot-middleware": {
"optional": true
},
"webpack-plugin-serve": {
"optional": true
}
}
},
"node_modules/@pnpm/config.env-replace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
@@ -19712,6 +19764,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ansi-html": {
"version": "0.0.9",
"resolved": "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.9.tgz",
"integrity": "sha512-ozbS3LuenHVxNRh/wdnN16QapUHzauqSomAl1jwwJRRsGwFwtj644lIhxfWu0Fy0acCij2+AEgHvjscq3dlVXg==",
"dev": true,
"engines": [
"node >= 0.8.0"
],
"license": "Apache-2.0",
"bin": {
"ansi-html": "bin/ansi-html"
}
},
"node_modules/ansi-html-community": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz",
@@ -25837,6 +25902,16 @@
"is-arrayish": "^0.2.1"
}
},
"node_modules/error-stack-parser": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
"integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"stackframe": "^1.3.4"
}
},
"node_modules/es-abstract": {
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
@@ -40640,9 +40715,10 @@
}
},
"node_modules/min-document": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
"integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==",
"version": "2.19.1",
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.1.tgz",
"integrity": "sha512-8lqe85PkqQJzIcs2iD7xW/WSxcncC3/DPVbTOafKNJDIMXwGfwXS350mH4SJslomntN2iYtFBuC0yNO3CEap6g==",
"license": "MIT",
"dependencies": {
"dom-walk": "^0.1.0"
}
@@ -49481,6 +49557,16 @@
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz",
@@ -54039,6 +54125,13 @@
"node": ">=0.1.14"
}
},
"node_modules/stackframe": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
"integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==",
"dev": true,
"license": "MIT"
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
@@ -61927,14 +62020,6 @@
"name": "@apache-superset/core",
"version": "0.0.1-rc5",
"license": "ISC",
"dependencies": {
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/inter": "^5.2.6",
"lodash": "^4.17.21"
},
"devDependencies": {
"@babel/cli": "^7.28.3",
"@babel/core": "^7.28.3",
@@ -61943,19 +62028,32 @@
"@babel/preset-typescript": "^7.26.0",
"@emotion/styled": "^11.14.1",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^12.8.3",
"@testing-library/react-hooks": "*",
"@testing-library/user-event": "*",
"@types/lodash": "^4.17.20",
"@types/react": "^17.0.83",
"@types/react": "*",
"@types/react-loadable": "*",
"@types/react-window": "^1.8.8",
"@types/tinycolor2": "*",
"install": "^0.13.0",
"npm": "^11.1.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.14.1",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/inter": "^5.2.6",
"antd": "^5.26.0",
"react": "^17.0.2"
"lodash": "^4.17.21",
"nanoid": "^5.0.9",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-loadable": "^5.5.0",
"tinycolor2": "*"
}
},
"packages/superset-core/node_modules/@types/lodash": {
@@ -65752,6 +65850,7 @@
"typescript": "^5.7.2"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@encodable/color": "=1.1.1",
"@superset-ui/core": "*",
"@superset-ui/legacy-plugin-chart-calendar": "*",
@@ -66336,6 +66435,7 @@
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@emotion/react": "^11.4.1",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
@@ -66361,6 +66461,7 @@
"react": "^19.2.0"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*"
}
@@ -66384,6 +66485,7 @@
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^17.0.2"
@@ -66399,6 +66501,7 @@
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^17.0.2"
@@ -66461,6 +66564,7 @@
"supercluster": "^8.0.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"mapbox-gl": "*",
@@ -66477,6 +66581,7 @@
"reactable": "^1.1.0"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^17.0.2"
@@ -66491,6 +66596,7 @@
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^17.0.2"
@@ -66506,6 +66612,7 @@
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/jest-dom": "*",
@@ -66524,6 +66631,7 @@
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@emotion/react": "^11.4.1",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
@@ -66542,6 +66650,7 @@
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^17.0.2"
@@ -66588,6 +66697,7 @@
"@types/urijs": "^1.19.25"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"mapbox-gl": "*",
@@ -66719,6 +66829,7 @@
"urijs": "^1.19.11"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^17.0.2"
@@ -66777,6 +66888,7 @@
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@apache-superset/core": "*",
"@reduxjs/toolkit": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
@@ -66831,6 +66943,7 @@
"jest": "^30.2.0"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"ace-builds": "^1.4.14",
@@ -69284,6 +69397,7 @@
"@types/d3-cloud": "^1.2.9"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@types/lodash": "*",
+2
View File
@@ -241,6 +241,7 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.56.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.17",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
"@storybook/addon-essentials": "8.1.11",
@@ -336,6 +337,7 @@
"prettier": "3.6.2",
"prettier-plugin-packagejson": "^2.5.19",
"process": "^0.11.10",
"react-refresh": "^0.14.2",
"react-resizable": "^3.0.5",
"redux-mock-store": "^1.5.4",
"sinon": "^18.0.0",
@@ -49,7 +49,9 @@ const LoaderWrapper = styled.div<{
&.inline-centered {
margin: 0 auto;
display: block;
display: flex;
align-items: center;
justify-content: center;
}
&.floating {
position: absolute;
@@ -94,6 +94,11 @@ const StyledTable = styled(Table)<{
}
}
.ant-table-row.table-row-highlighted > td.ant-table-cell,
.ant-table-row.table-row-highlighted > td.ant-table-cell.ant-table-cell-row-hover {
background-color: ${theme.colorPrimaryBg};
}
.ant-table-cell {
max-width: 320px;
font-feature-settings: 'tnum' 1;
@@ -155,6 +160,7 @@ function TableCollection<T extends object>({
columns,
rows,
loading,
highlightRowId,
setSortBy,
headerGroups,
columnsForWrapText,
@@ -272,6 +278,12 @@ function TableCollection<T extends object>({
onPageChange,
]);
const getRowClassName = useCallback(
(record: Record<string, unknown>) =>
record?.id === highlightRowId ? 'table-row-highlighted' : '',
[highlightRowId],
);
return (
<StyledTable
loading={loading}
@@ -289,6 +301,7 @@ function TableCollection<T extends object>({
sortDirections={['ascend', 'descend', 'ascend']}
isPaginationSticky={isPaginationSticky}
showRowCount={showRowCount}
rowClassName={getRowClassName}
components={{
header: {
cell: (props: HTMLAttributes<HTMLTableCellElement>) => (
@@ -67,6 +67,7 @@ export function normalizeTimeColumn(
sqlExpression: formData.x_axis,
label: formData.x_axis,
expressionType: 'SQL',
isColumnReference: true,
};
}
@@ -27,6 +27,7 @@ export interface AdhocColumn {
optionName?: string;
sqlExpression: string;
expressionType: 'SQL';
isColumnReference?: boolean;
columnType?: 'BASE_AXIS' | 'SERIES';
timeGrain?: string;
datasourceWarning?: boolean;
@@ -74,6 +75,10 @@ export function isAdhocColumn(column?: any): column is AdhocColumn {
);
}
export function isAdhocColumnReference(column?: any): column is AdhocColumn {
return isAdhocColumn(column) && column?.isColumnReference === true;
}
export function isQueryFormColumn(column: any): column is QueryFormColumn {
return isPhysicalColumn(column) || isAdhocColumn(column);
}
@@ -86,6 +86,7 @@ test('should support different columns for x-axis and granularity', () => {
{
timeGrain: 'P1Y',
columnType: 'BASE_AXIS',
isColumnReference: true,
sqlExpression: 'time_column_in_x_axis',
label: 'time_column_in_x_axis',
expressionType: 'SQL',
+7
View File
@@ -26,6 +26,13 @@ export default defineConfig({
// Test directory
testDir: './playwright/tests',
// Conditionally ignore experimental tests based on env var
// When INCLUDE_EXPERIMENTAL=true, experimental tests are included
// Otherwise, they are excluded (default for required tests)
testIgnore: process.env.INCLUDE_EXPERIMENTAL
? undefined
: '**/experimental/**',
// Timeout settings
timeout: 30000,
expect: { timeout: 8000 },
@@ -0,0 +1,70 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# Experimental Playwright Tests
This directory contains Playwright tests that are still under development or validation.
## Purpose
Tests in this directory run in "shadow mode" with `continue-on-error: true` in CI:
- Failures do NOT block PR merges
- Allows tests to run in CI to validate stability before promotion
- Provides visibility into test reliability over time
## Promoting Tests to Stable
Once a test has proven stable (no false positives/negatives over sufficient time):
1. Move the test file out of `experimental/` to the appropriate feature directory:
```bash
# From the repository root:
git mv superset-frontend/playwright/tests/experimental/dashboard/test.spec.ts \
superset-frontend/playwright/tests/dashboard/
# Or from the superset-frontend/ directory:
git mv playwright/tests/experimental/dashboard/test.spec.ts \
playwright/tests/dashboard/
```
2. The test will automatically become required for merge
## Test Organization
Organize tests by feature area:
- `auth/` - Authentication and authorization tests
- `dashboard/` - Dashboard functionality tests
- `explore/` - Chart builder tests
- `sqllab/` - SQL Lab tests
- etc.
## Running Tests
```bash
# Run all experimental tests (requires INCLUDE_EXPERIMENTAL env var)
INCLUDE_EXPERIMENTAL=true npm run playwright:test -- experimental/
# Run specific experimental test
INCLUDE_EXPERIMENTAL=true npm run playwright:test -- experimental/dashboard/test.spec.ts
# Run in UI mode for debugging
INCLUDE_EXPERIMENTAL=true npm run playwright:ui -- experimental/
```
**Note**: The `INCLUDE_EXPERIMENTAL=true` environment variable is required because experimental tests are filtered out by default in `playwright.config.ts`. Without it, Playwright will report "No tests found".
@@ -101,36 +101,35 @@ describe('queryObject conversion', () => {
it('should convert queryObject', () => {
const { queries } = buildQuery({ ...formData, x_axis: 'time_column' });
expect(queries[0]).toEqual(
expect.objectContaining({
granularity: 'time_column',
time_range: '1 year ago : 2013',
extras: { having: '', where: '', time_grain_sqla: 'P1Y' },
columns: [
{
columnType: 'BASE_AXIS',
expressionType: 'SQL',
label: 'time_column',
sqlExpression: 'time_column',
timeGrain: 'P1Y',
expect(queries[0]).toMatchObject({
granularity: 'time_column',
time_range: '1 year ago : 2013',
extras: { having: '', where: '', time_grain_sqla: 'P1Y' },
columns: [
{
columnType: 'BASE_AXIS',
expressionType: 'SQL',
label: 'time_column',
sqlExpression: 'time_column',
timeGrain: 'P1Y',
isColumnReference: true,
},
'col1',
],
series_columns: ['col1'],
metrics: ['count(*)'],
post_processing: [
{
operation: 'pivot',
options: {
aggregates: { 'count(*)': { operator: 'mean' } },
columns: ['col1'],
drop_missing_columns: true,
index: ['time_column'],
},
'col1',
],
series_columns: ['col1'],
metrics: ['count(*)'],
post_processing: [
{
operation: 'pivot',
options: {
aggregates: { 'count(*)': { operator: 'mean' } },
columns: ['col1'],
drop_missing_columns: true,
index: ['time_column'],
},
},
{ operation: 'flatten' },
],
}),
);
},
{ operation: 'flatten' },
],
});
});
});
@@ -139,6 +139,31 @@ function cellWidth({
return perc2;
}
/**
* Sanitize a column identifier for use in HTML id attributes and CSS selectors.
* Replaces characters that are invalid in CSS selectors with safe alternatives.
*
* Note: The returned value should be prefixed with a string (e.g., "header-")
* to ensure it forms a valid HTML ID (IDs cannot start with a digit).
*
* Exported for testing.
*/
export function sanitizeHeaderId(columnId: string): string {
return (
columnId
// Semantic replacements first: preserve meaning in IDs for readability
// (e.g., '%pct_nice' → 'percentpct_nice' instead of '_pct_nice')
.replace(/%/g, 'percent')
.replace(/#/g, 'hash')
.replace(/△/g, 'delta')
// Generic sanitization for remaining special characters
.replace(/\s+/g, '_')
.replace(/[^a-zA-Z0-9_-]/g, '_')
.replace(/_+/g, '_') // Collapse consecutive underscores
.replace(/^_+|_+$/g, '') // Trim leading/trailing underscores
);
}
/**
* Cell left margin (offset) calculation for horizontal bar chart elements
* when alignPositiveNegative is not set
@@ -844,6 +869,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
}
}
// Cache sanitized header ID to avoid recomputing it multiple times
const headerId = sanitizeHeaderId(column.originalLabel ?? column.key);
return {
id: String(i), // to allow duplicate column keys
// must use custom accessor to allow `.` in column names
@@ -969,7 +997,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
}
const cellProps = {
'aria-labelledby': `header-${column.key}`,
'aria-labelledby': `header-${headerId}`,
role: 'cell',
// show raw number in title in case of numeric values
title: typeof value === 'number' ? String(value) : undefined,
@@ -1056,7 +1084,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
},
Header: ({ column: col, onClick, style, onDragStart, onDrop }) => (
<th
id={`header-${column.originalLabel}`}
id={`header-${headerId}`}
title={t('Shift + Click to sort by multiple columns')}
className={[className, col.isSorted ? 'is-sorted' : ''].join(' ')}
style={{
@@ -18,15 +18,93 @@
*/
import '@testing-library/jest-dom';
import { render, screen } from '@superset-ui/core/spec';
import TableChart from '../src/TableChart';
import { cloneDeep } from 'lodash';
import TableChart, { sanitizeHeaderId } from '../src/TableChart';
import transformProps from '../src/transformProps';
import DateWithFormatter from '../src/utils/DateWithFormatter';
import testData from './testData';
import { ProviderWrapper } from './testHelpers';
test('sanitizeHeaderId should sanitize percent sign', () => {
expect(sanitizeHeaderId('%pct_nice')).toBe('percentpct_nice');
});
test('sanitizeHeaderId should sanitize hash/pound sign', () => {
expect(sanitizeHeaderId('# metric_1')).toBe('hash_metric_1');
});
test('sanitizeHeaderId should sanitize delta symbol', () => {
expect(sanitizeHeaderId('△ delta')).toBe('delta_delta');
});
test('sanitizeHeaderId should replace spaces with underscores', () => {
expect(sanitizeHeaderId('Main metric_1')).toBe('Main_metric_1');
expect(sanitizeHeaderId('multiple spaces')).toBe('multiple_spaces');
});
test('sanitizeHeaderId should handle multiple special characters', () => {
expect(sanitizeHeaderId('% #△ test')).toBe('percent_hashdelta_test');
expect(sanitizeHeaderId('% # △ test')).toBe('percent_hash_delta_test');
});
test('sanitizeHeaderId should preserve alphanumeric, underscore, and hyphen', () => {
expect(sanitizeHeaderId('valid-name_123')).toBe('valid-name_123');
});
test('sanitizeHeaderId should replace other special characters with underscore', () => {
expect(sanitizeHeaderId('col@name!test')).toBe('col_name_test');
expect(sanitizeHeaderId('test.column')).toBe('test_column');
});
test('sanitizeHeaderId should handle edge cases', () => {
expect(sanitizeHeaderId('')).toBe('');
expect(sanitizeHeaderId('simple')).toBe('simple');
});
test('sanitizeHeaderId should collapse consecutive underscores', () => {
expect(sanitizeHeaderId('test @@ space')).toBe('test_space');
expect(sanitizeHeaderId('col___name')).toBe('col_name');
expect(sanitizeHeaderId('a b c')).toBe('a_b_c');
expect(sanitizeHeaderId('test@@name')).toBe('test_name');
});
test('sanitizeHeaderId should remove leading underscores', () => {
expect(sanitizeHeaderId('@col')).toBe('col');
expect(sanitizeHeaderId('!revenue')).toBe('revenue');
expect(sanitizeHeaderId('@@test')).toBe('test');
expect(sanitizeHeaderId(' leading_spaces')).toBe('leading_spaces');
});
test('sanitizeHeaderId should remove trailing underscores', () => {
expect(sanitizeHeaderId('col@')).toBe('col');
expect(sanitizeHeaderId('revenue!')).toBe('revenue');
expect(sanitizeHeaderId('test@@')).toBe('test');
expect(sanitizeHeaderId('trailing_spaces ')).toBe('trailing_spaces');
});
test('sanitizeHeaderId should remove leading and trailing underscores', () => {
expect(sanitizeHeaderId('@col@')).toBe('col');
expect(sanitizeHeaderId('!test!')).toBe('test');
expect(sanitizeHeaderId(' spaced ')).toBe('spaced');
expect(sanitizeHeaderId('@@multiple@@')).toBe('multiple');
});
test('sanitizeHeaderId should handle inputs with only special characters', () => {
expect(sanitizeHeaderId('@')).toBe('');
expect(sanitizeHeaderId('@@')).toBe('');
expect(sanitizeHeaderId(' ')).toBe('');
expect(sanitizeHeaderId('!@$')).toBe('');
expect(sanitizeHeaderId('!@#$')).toBe('hash'); // # is replaced with 'hash' (semantic replacement)
// Semantic replacements produce readable output even when alone
expect(sanitizeHeaderId('%')).toBe('percent');
expect(sanitizeHeaderId('#')).toBe('hash');
expect(sanitizeHeaderId('△')).toBe('delta');
expect(sanitizeHeaderId('% # △')).toBe('percent_hash_delta');
});
describe('plugin-chart-table', () => {
describe('transformProps', () => {
it('should parse pageLength to pageSize', () => {
test('should parse pageLength to pageSize', () => {
expect(transformProps(testData.basic).pageSize).toBe(20);
expect(
transformProps({
@@ -42,13 +120,13 @@ describe('plugin-chart-table', () => {
).toBe(0);
});
it('should memoize data records', () => {
test('should memoize data records', () => {
expect(transformProps(testData.basic).data).toBe(
transformProps(testData.basic).data,
);
});
it('should memoize columns meta', () => {
test('should memoize columns meta', () => {
expect(transformProps(testData.basic).columns).toBe(
transformProps({
...testData.basic,
@@ -57,14 +135,14 @@ describe('plugin-chart-table', () => {
);
});
it('should format timestamp', () => {
test('should format timestamp', () => {
// eslint-disable-next-line no-underscore-dangle
const parsedDate = transformProps(testData.basic).data[0]
.__timestamp as DateWithFormatter;
expect(String(parsedDate)).toBe('2020-01-01 12:34:56');
expect(parsedDate.getTime()).toBe(1577882096000);
});
it('should process comparison columns when time_compare and comparison_type are set', () => {
test('should process comparison columns when time_compare and comparison_type are set', () => {
const transformedProps = transformProps(testData.comparison);
const comparisonColumns = transformedProps.columns.filter(
col =>
@@ -86,7 +164,7 @@ describe('plugin-chart-table', () => {
expect(comparisonColumns.some(col => col.label === '%')).toBe(true);
});
it('should not process comparison columns when time_compare is empty', () => {
test('should not process comparison columns when time_compare is empty', () => {
const propsWithoutTimeCompare = {
...testData.comparison,
rawFormData: {
@@ -109,7 +187,7 @@ describe('plugin-chart-table', () => {
expect(comparisonColumns.length).toBe(0);
});
it('should correctly apply column configuration for comparison columns', () => {
test('should correctly apply column configuration for comparison columns', () => {
const transformedProps = transformProps(testData.comparisonWithConfig);
const comparisonColumns = transformedProps.columns.filter(
@@ -147,7 +225,7 @@ describe('plugin-chart-table', () => {
expect(percentMetricConfig?.config).toEqual({ d3NumberFormat: '.3f' });
});
it('should correctly format comparison columns using getComparisonColFormatter', () => {
test('should correctly format comparison columns using getComparisonColFormatter', () => {
const transformedProps = transformProps(testData.comparisonWithConfig);
const comparisonColumns = transformedProps.columns.filter(
col =>
@@ -178,7 +256,7 @@ describe('plugin-chart-table', () => {
expect(formattedPercentMetric).toBe('0.123');
});
it('should set originalLabel for comparison columns when time_compare and comparison_type are set', () => {
test('should set originalLabel for comparison columns when time_compare and comparison_type are set', () => {
const transformedProps = transformProps(testData.comparison);
// Check if comparison columns are processed
@@ -265,7 +343,7 @@ describe('plugin-chart-table', () => {
});
describe('TableChart', () => {
it('render basic data', () => {
test('render basic data', () => {
render(
<TableChart {...transformProps(testData.basic)} sticky={false} />,
);
@@ -284,12 +362,9 @@ describe('plugin-chart-table', () => {
expect(cells[8]).toHaveTextContent('N/A');
});
it('render advanced data', () => {
test('render advanced data', () => {
render(
<>
<TableChart {...transformProps(testData.advanced)} sticky={false} />
,
</>,
<TableChart {...transformProps(testData.advanced)} sticky={false} />,
);
const secondColumnHeader = screen.getByText('Sum of Num');
expect(secondColumnHeader).toBeInTheDocument();
@@ -304,7 +379,7 @@ describe('plugin-chart-table', () => {
expect(cells[4]).toHaveTextContent('2.47k');
});
it('render advanced data with currencies', () => {
test('render advanced data with currencies', () => {
render(
ProviderWrapper({
children: (
@@ -324,7 +399,7 @@ describe('plugin-chart-table', () => {
expect(cells[4]).toHaveTextContent('$ 2.47k');
});
it('render data with a bigint value in a raw record mode', () => {
test('render data with a bigint value in a raw record mode', () => {
render(
ProviderWrapper({
children: (
@@ -345,7 +420,7 @@ describe('plugin-chart-table', () => {
expect(cells[3]).toHaveTextContent('1234567890123456789');
});
it('render raw data', () => {
test('render raw data', () => {
const props = transformProps({
...testData.raw,
rawFormData: { ...testData.raw.rawFormData },
@@ -362,7 +437,7 @@ describe('plugin-chart-table', () => {
expect(cells[1]).toHaveTextContent('0');
});
it('render raw data with currencies', () => {
test('render raw data with currencies', () => {
const props = transformProps({
...testData.raw,
rawFormData: {
@@ -387,7 +462,7 @@ describe('plugin-chart-table', () => {
expect(cells[2]).toHaveTextContent('$ 0');
});
it('render small formatted data with currencies', () => {
test('render small formatted data with currencies', () => {
const props = transformProps({
...testData.raw,
rawFormData: {
@@ -429,14 +504,14 @@ describe('plugin-chart-table', () => {
expect(cells[2]).toHaveTextContent('$ 0.61');
});
it('render empty data', () => {
test('render empty data', () => {
render(
<TableChart {...transformProps(testData.empty)} sticky={false} />,
);
expect(screen.getByText('No records found')).toBeInTheDocument();
});
it('render color with column color formatter', () => {
test('render color with column color formatter', () => {
render(
ProviderWrapper({
children: (
@@ -466,8 +541,8 @@ describe('plugin-chart-table', () => {
expect(getComputedStyle(screen.getByTitle('2467')).background).toBe('');
});
it('render cell without color', () => {
const dataWithEmptyCell = testData.advanced.queriesData[0];
test('render cell without color', () => {
const dataWithEmptyCell = cloneDeep(testData.advanced.queriesData[0]);
dataWithEmptyCell.data.push({
__timestamp: null,
name: 'Noah',
@@ -507,7 +582,7 @@ describe('plugin-chart-table', () => {
);
expect(getComputedStyle(screen.getByText('N/A')).background).toBe('');
});
it('should display original label in grouped headers', () => {
test('should display original label in grouped headers', () => {
const props = transformProps(testData.comparison);
render(<TableChart {...props} sticky={false} />);
@@ -522,7 +597,142 @@ describe('plugin-chart-table', () => {
expect(hasMetricHeaders).toBe(true);
});
it('render cell bars properly, and only when it is toggled on in both regular and percent metrics', () => {
test('should set meaningful header IDs for time-comparison columns', () => {
// Test time-comparison columns have proper IDs
// Uses originalLabel (e.g., "metric_1") which is sanitized for CSS safety
const props = transformProps(testData.comparison);
const { container } = render(<TableChart {...props} sticky={false} />);
const headers = screen.getAllByRole('columnheader');
// All headers should have IDs
const headersWithIds = headers.filter(header => header.id);
expect(headersWithIds.length).toBeGreaterThan(0);
// None should have "header-undefined"
const undefinedHeaders = headersWithIds.filter(header =>
header.id.includes('undefined'),
);
expect(undefinedHeaders).toHaveLength(0);
// Should have IDs based on sanitized originalLabel (e.g., "metric_1")
const hasMetricHeaders = headersWithIds.some(
header =>
header.id.includes('metric_1') || header.id.includes('metric_2'),
);
expect(hasMetricHeaders).toBe(true);
// CRITICAL: Verify sanitization - no spaces or special chars in any header ID
headersWithIds.forEach(header => {
// IDs must not contain spaces (would break CSS selectors and ARIA)
expect(header.id).not.toMatch(/\s/);
// IDs must not contain special chars like %, #, △
expect(header.id).not.toMatch(/[%#△]/);
// IDs should only contain valid characters: alphanumeric, underscore, hyphen
expect(header.id).toMatch(/^header-[a-zA-Z0-9_-]+$/);
});
// CRITICAL: Verify ALL cells reference valid headers (no broken ARIA)
const cellsWithLabels = container.querySelectorAll(
'td[aria-labelledby]',
);
cellsWithLabels.forEach(cell => {
const labelledBy = cell.getAttribute('aria-labelledby');
if (labelledBy) {
// Check that the ID doesn't contain spaces (would be interpreted as multiple IDs)
expect(labelledBy).not.toMatch(/\s/);
// Check that the ID doesn't contain special characters
expect(labelledBy).not.toMatch(/[%#△]/);
// Verify the referenced header actually exists
const referencedHeader = container.querySelector(
`#${CSS.escape(labelledBy)}`,
);
expect(referencedHeader).toBeTruthy();
}
});
});
test('should set meaningful header IDs for regular table columns', () => {
// Test regular (non-time-comparison) columns have proper IDs
// Uses fallback to column.key since originalLabel is undefined
const props = transformProps(testData.advanced);
const { container } = render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
const headers = screen.getAllByRole('columnheader');
// Test 1: "name" column (regular string column)
const nameHeader = headers.find(header =>
header.textContent?.includes('name'),
);
expect(nameHeader).toBeDefined();
expect(nameHeader?.id).toBe('header-name'); // Falls back to column.key
// Verify cells reference this header correctly
const nameCells = container.querySelectorAll(
'td[aria-labelledby="header-name"]',
);
expect(nameCells.length).toBeGreaterThan(0);
// Test 2: "sum__num" column (metric with verbose map "Sum of Num")
const sumHeader = headers.find(header =>
header.textContent?.includes('Sum of Num'),
);
expect(sumHeader).toBeDefined();
expect(sumHeader?.id).toBe('header-sum_num'); // Falls back to column.key, consecutive underscores collapsed
// Verify cells reference this header correctly
const sumCells = container.querySelectorAll(
'td[aria-labelledby="header-sum_num"]',
);
expect(sumCells.length).toBeGreaterThan(0);
// Test 3: Verify NO headers have "undefined" in their ID
const undefinedHeaders = headers.filter(header =>
header.id?.includes('undefined'),
);
expect(undefinedHeaders).toHaveLength(0);
// Test 4: Verify ALL headers have proper IDs (no missing IDs)
const headersWithIds = headers.filter(header => header.id);
expect(headersWithIds.length).toBe(headers.length);
// Test 5: Verify ALL header IDs are properly sanitized
headersWithIds.forEach(header => {
// IDs must not contain spaces
expect(header.id).not.toMatch(/\s/);
// IDs must not contain special chars like % (from %pct_nice column)
expect(header.id).not.toMatch(/[%#△]/);
// IDs should only contain valid CSS selector characters
expect(header.id).toMatch(/^header-[a-zA-Z0-9_-]+$/);
});
// Test 6: Verify ALL cells reference valid headers (no broken ARIA)
const cellsWithLabels = container.querySelectorAll(
'td[aria-labelledby]',
);
cellsWithLabels.forEach(cell => {
const labelledBy = cell.getAttribute('aria-labelledby');
if (labelledBy) {
// Verify no spaces (would be interpreted as multiple IDs)
expect(labelledBy).not.toMatch(/\s/);
// Verify no special characters
expect(labelledBy).not.toMatch(/[%#△]/);
// Verify the referenced header actually exists
const referencedHeader = container.querySelector(
`#${CSS.escape(labelledBy)}`,
);
expect(referencedHeader).toBeTruthy();
}
});
});
test('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 },
@@ -572,7 +782,7 @@ describe('plugin-chart-table', () => {
cells = document.querySelectorAll('td');
});
it('render color with string column color formatter(operator begins with)', () => {
test('render color with string column color formatter(operator begins with)', () => {
render(
ProviderWrapper({
children: (
@@ -604,7 +814,7 @@ describe('plugin-chart-table', () => {
);
});
it('render color with string column color formatter (operator ends with)', () => {
test('render color with string column color formatter (operator ends with)', () => {
render(
ProviderWrapper({
children: (
@@ -633,7 +843,7 @@ describe('plugin-chart-table', () => {
expect(getComputedStyle(screen.getByText('Joe')).background).toBe('');
});
it('render color with string column color formatter (operator containing)', () => {
test('render color with string column color formatter (operator containing)', () => {
render(
ProviderWrapper({
children: (
@@ -662,7 +872,7 @@ describe('plugin-chart-table', () => {
expect(getComputedStyle(screen.getByText('Joe')).background).toBe('');
});
it('render color with string column color formatter (operator not containing)', () => {
test('render color with string column color formatter (operator not containing)', () => {
render(
ProviderWrapper({
children: (
@@ -693,7 +903,7 @@ describe('plugin-chart-table', () => {
);
});
it('render color with string column color formatter (operator =)', () => {
test('render color with string column color formatter (operator =)', () => {
render(
ProviderWrapper({
children: (
@@ -724,7 +934,7 @@ describe('plugin-chart-table', () => {
);
});
it('render color with string column color formatter (operator None)', () => {
test('render color with string column color formatter (operator None)', () => {
render(
ProviderWrapper({
children: (
@@ -757,7 +967,7 @@ describe('plugin-chart-table', () => {
);
});
it('render color with column color formatter to entire row', () => {
test('render color with column color formatter to entire row', () => {
render(
ProviderWrapper({
children: (
@@ -793,7 +1003,7 @@ describe('plugin-chart-table', () => {
);
});
it('display text color using column color formatter', () => {
test('display text color using column color formatter', () => {
render(
ProviderWrapper({
children: (
@@ -826,7 +1036,7 @@ describe('plugin-chart-table', () => {
);
});
it('display text color using column color formatter for entire row', () => {
test('display text color using column color formatter for entire row', () => {
render(
ProviderWrapper({
children: (
@@ -950,7 +950,13 @@ export function mergeTable(table, query, prepend) {
return { type: MERGE_TABLE, table, query, prepend };
}
export function addTable(queryEditor, tableName, catalogName, schemaName) {
export function addTable(
queryEditor,
tableName,
catalogName,
schemaName,
expanded = true,
) {
return function (dispatch, getState) {
const { dbId } = getUpToDateQuery(getState(), queryEditor, queryEditor.id);
const table = {
@@ -964,7 +970,7 @@ export function addTable(queryEditor, tableName, catalogName, schemaName) {
mergeTable({
...table,
id: nanoid(11),
expanded: true,
expanded,
}),
);
};
@@ -153,6 +153,7 @@ export function useKeywords(
data.value,
catalog,
schema,
false, // Don't auto-expand/switch tabs when adding via autocomplete
),
);
}
@@ -61,6 +61,10 @@ jest.mock('src/SqlLab/actions/sqlLab', () => ({
jest.mock('src/explore/exploreUtils/formData', () => ({
postFormData: jest.fn(),
}));
jest.mock('src/utils/cachedSupersetGet', () => ({
...jest.requireActual('src/utils/cachedSupersetGet'),
clearDatasetCache: jest.fn(),
}));
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('SaveDatasetModal', () => {
@@ -336,4 +340,42 @@ describe('SaveDatasetModal', () => {
templateParams: undefined,
});
});
test('clears dataset cache when creating new dataset', async () => {
const clearDatasetCache = jest.spyOn(
require('src/utils/cachedSupersetGet'),
'clearDatasetCache',
);
const postFormData = jest.spyOn(
require('src/explore/exploreUtils/formData'),
'postFormData',
);
const dummyDispatch = jest.fn().mockResolvedValue({ id: 123 });
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue({ ...user });
postFormData.mockResolvedValue('chart_key_123');
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
const saveConfirmationBtn = screen.getByRole('button', {
name: /save/i,
});
userEvent.click(saveConfirmationBtn);
await waitFor(() => {
expect(clearDatasetCache).toHaveBeenCalledWith(123);
});
});
test('clearDatasetCache is imported and available', () => {
const clearDatasetCache =
require('src/utils/cachedSupersetGet').clearDatasetCache;
expect(clearDatasetCache).toBeDefined();
expect(typeof clearDatasetCache).toBe('function');
});
});
@@ -59,6 +59,7 @@ import { mountExploreUrl } from 'src/explore/exploreUtils';
import { postFormData } from 'src/explore/exploreUtils/formData';
import { URL_PARAMS } from 'src/constants';
import { isEmpty } from 'lodash';
import { clearDatasetCache } from 'src/utils/cachedSupersetGet';
interface QueryDatabase {
id?: number;
@@ -170,6 +171,9 @@ const updateDataset = async (
headers,
body,
});
clearDatasetCache(datasetId);
return data.json.result;
};
@@ -347,15 +351,17 @@ export const SaveDatasetModal = ({
datasourceName: datasetName,
}),
)
.then((data: { id: number }) =>
postFormData(data.id, 'table', {
.then((data: { id: number }) => {
clearDatasetCache(data.id);
return postFormData(data.id, 'table', {
...formDataWithDefaults,
datasource: `${data.id}__table`,
...(defaultVizType === VizType.Table && {
all_columns: selectedColumns.map(column => column.column_name),
}),
}),
)
});
})
.then((key: string) => {
setLoading(false);
const url = mountExploreUrl(null, {
@@ -36,6 +36,14 @@ const StyledConfigEditor = styled(ConfigEditor)`
}
`;
const StyledParagraph = styled.p`
margin-top: 0;
`;
const Code = styled.code`
color: ${({ theme }) => theme.colorPrimary};
`;
export type TemplateParamsEditorProps = {
queryEditorId: string;
language: 'yaml' | 'json';
@@ -65,13 +73,11 @@ const TemplateParamsEditor = ({
const modalBody = (
<div>
<p>
{t('Assign a set of parameters as')}
<code>JSON</code>
{t('below (example:')}
<code>{'{"my_table": "foo"}'}</code>
{t('), and they become available in your SQL (example:')}
<code>SELECT * FROM {'{{ my_table }}'} </code>) {t('by using')}&nbsp;
<StyledParagraph>
{t('Assign a set of parameters as')} <Code>JSON</Code>{' '}
{t('below (example:')} <Code>{'{"my_table": "foo"}'}</Code>
{t('), and they become available in your SQL (example:')}{' '}
<Code>SELECT * FROM {'{{ my_table }}'} </Code>) {t('by using')}&nbsp;
<a
href="https://superset.apache.org/sqllab.html#templating-with-jinja"
target="_blank"
@@ -80,7 +86,7 @@ const TemplateParamsEditor = ({
{t('Jinja templating')}
</a>{' '}
{t('syntax.')}
</p>
</StyledParagraph>
<StyledConfigEditor
mode={language}
minLines={25}
@@ -222,8 +222,10 @@ export default function sqlLabReducer(state = {}, action) {
}
// for new table, associate Id of query for data preview
at.dataPreviewQueryId = null;
let newState = addToArr(state, 'tables', at, Boolean(action.prepend));
newState.activeSouthPaneTab = at.id;
let newState = {
...addToArr(state, 'tables', at, Boolean(action.prepend)),
...(at.expanded && { activeSouthPaneTab: at.id }),
};
if (action.query) {
newState = alterInArr(newState, 'tables', at, {
dataPreviewQueryId: action.query.id,
@@ -370,6 +370,93 @@ describe('sqlLabReducer', () => {
newState = sqlLabReducer(newState, action);
expect(newState.tables).toHaveLength(0);
});
test('should set activeSouthPaneTab when adding expanded table', () => {
const expandedTable = {
...table,
id: 'expanded_table_id',
name: 'expanded_table',
expanded: true,
};
const action = {
type: actions.MERGE_TABLE,
table: expandedTable,
};
newState = sqlLabReducer(initialState, action);
expect(newState.tables).toHaveLength(1);
expect(newState.activeSouthPaneTab).toBe(expandedTable.id);
});
test('should not set activeSouthPaneTab when adding collapsed table', () => {
const collapsedTable = {
...table,
id: 'collapsed_table_id',
name: 'collapsed_table',
expanded: false,
};
const action = {
type: actions.MERGE_TABLE,
table: collapsedTable,
};
newState = sqlLabReducer(initialState, action);
expect(newState.tables).toHaveLength(1);
expect(newState.activeSouthPaneTab).toBe(initialState.activeSouthPaneTab);
});
test('should set activeSouthPaneTab when merging existing table with expanded=true', () => {
// First add a table with expanded=false
const collapsedTable = {
...table,
id: 'existing_table_id',
name: 'existing_table',
expanded: false,
};
const addAction = {
type: actions.MERGE_TABLE,
table: collapsedTable,
};
newState = sqlLabReducer(initialState, addAction);
const previousActiveSouthPaneTab = newState.activeSouthPaneTab;
// Now merge the same table with expanded=true
const expandedTable = {
...collapsedTable,
expanded: true,
};
const mergeAction = {
type: actions.MERGE_TABLE,
table: expandedTable,
};
newState = sqlLabReducer(newState, mergeAction);
expect(newState.tables).toHaveLength(1);
expect(newState.activeSouthPaneTab).toBe(expandedTable.id);
expect(newState.activeSouthPaneTab).not.toBe(previousActiveSouthPaneTab);
});
test('should not set activeSouthPaneTab when merging existing table with expanded=false', () => {
// First add a table with expanded=true
const expandedTable = {
...table,
id: 'existing_table_id_2',
name: 'existing_table_2',
expanded: true,
};
const addAction = {
type: actions.MERGE_TABLE,
table: expandedTable,
};
newState = sqlLabReducer(initialState, addAction);
expect(newState.activeSouthPaneTab).toBe(expandedTable.id);
// Now merge the same table with expanded=false
const collapsedTable = {
...expandedTable,
expanded: false,
};
const mergeAction = {
type: actions.MERGE_TABLE,
table: collapsedTable,
};
newState = sqlLabReducer(newState, mergeAction);
expect(newState.tables).toHaveLength(1);
expect(newState.activeSouthPaneTab).toBe(expandedTable.id);
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('Run Query', () => {
@@ -193,6 +193,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
const { json } = await SupersetClient.get({
endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
});
addSuccessToast(t('The dataset has been saved'));
// eslint-disable-next-line no-param-reassign
json.result.type = 'table';
@@ -71,6 +71,7 @@ import {
resetDatabaseState,
} from 'src/database/actions';
import Mousetrap from 'mousetrap';
import { clearDatasetCache } from 'src/utils/cachedSupersetGet';
import { DatabaseSelector } from '../../../DatabaseSelector';
import CollectionTable from '../CollectionTable';
import Fieldset from '../Fieldset';
@@ -840,6 +841,9 @@ class DatasourceEditor extends PureComponent {
col => !col.expression, // remove calculated columns
),
});
clearDatasetCache(datasource.id);
this.props.addSuccessToast(t('Metadata has been synced'));
this.setState({ metadataLoading: false });
} catch (error) {
+5
View File
@@ -103,6 +103,10 @@ export const URL_PARAMS = {
name: 'focused_chart',
type: 'number',
},
editMode: {
name: 'edit',
type: 'boolean',
},
} as const;
export const RESERVED_CHART_URL_PARAMS: string[] = [
@@ -117,6 +121,7 @@ export const RESERVED_DASHBOARD_URL_PARAMS: string[] = [
URL_PARAMS.nativeFiltersKey.name,
URL_PARAMS.permalinkKey.name,
URL_PARAMS.preselectFilters.name,
URL_PARAMS.editMode.name,
];
export const DEFAULT_COMMON_BOOTSTRAP_DATA: CommonBootstrapData = {
@@ -275,6 +275,85 @@ describe('DashboardBuilder', () => {
expect(filterbar).toHaveStyleRule('width', `${expectedValue}px`);
});
test('should set header max width based on open filter bar width', () => {
const expectedValue = 320;
const setter = jest.fn();
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
expectedValue,
setter,
]);
const nativeFiltersSpy = jest
.spyOn(useNativeFiltersModule, 'useNativeFilters')
.mockReturnValue({
showDashboard: true,
missingInitialFilters: [],
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
});
const { getByTestId } = setup();
expect(getByTestId('dashboard-header-wrapper')).toHaveStyleRule(
'max-width',
`calc(100vw - ${expectedValue}px)`,
);
nativeFiltersSpy.mockRestore();
});
test('should use closed filter bar width when the panel is collapsed', () => {
const setter = jest.fn();
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
OPEN_FILTER_BAR_WIDTH,
setter,
]);
const nativeFiltersSpy = jest
.spyOn(useNativeFiltersModule, 'useNativeFilters')
.mockReturnValue({
showDashboard: true,
missingInitialFilters: [],
dashboardFiltersOpen: false,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
});
const { getByTestId } = setup();
expect(getByTestId('dashboard-header-wrapper')).toHaveStyleRule(
'max-width',
`calc(100vw - ${CLOSED_FILTER_BAR_WIDTH}px)`,
);
nativeFiltersSpy.mockRestore();
});
test('should not constrain header width when filter bar is hidden', () => {
const setter = jest.fn();
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
OPEN_FILTER_BAR_WIDTH,
setter,
]);
const nativeFiltersSpy = jest
.spyOn(useNativeFiltersModule, 'useNativeFilters')
.mockReturnValue({
showDashboard: true,
missingInitialFilters: [],
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: false,
});
const { getByTestId } = setup();
expect(getByTestId('dashboard-header-wrapper')).toHaveStyleRule(
'max-width',
'calc(100vw - 0px)',
);
nativeFiltersSpy.mockRestore();
});
test('filter panel state when featureflag is true', () => {
window.featureFlags = {
[FeatureFlag.FilterBarClosedByDefault]: true,
@@ -89,14 +89,14 @@ const StickyPanel = styled.div<{ width: number }>`
`;
// @z-index-above-dashboard-popovers (99) + 1 = 100
const StyledHeader = styled.div`
${({ theme }) => css`
const StyledHeader = styled.div<{ filterBarWidth: number }>`
${({ theme, filterBarWidth }) => css`
grid-column: 2;
grid-row: 1;
position: sticky;
top: 0;
z-index: 99;
max-width: 100vw;
max-width: calc(100vw - ${filterBarWidth}px);
.empty-droptarget:before {
position: absolute;
@@ -419,6 +419,9 @@ const DashboardBuilder = () => {
isReport;
const [barTopOffset, setBarTopOffset] = useState(0);
const [currentFilterBarWidth, setCurrentFilterBarWidth] = useState(
CLOSED_FILTER_BAR_WIDTH,
);
useEffect(() => {
setBarTopOffset(headerRef.current?.getBoundingClientRect()?.height || 0);
@@ -516,6 +519,7 @@ const DashboardBuilder = () => {
shouldFocus={shouldFocusTabs}
menuItems={[
<IconButton
key="collapse-tabs"
icon={<Icons.FallOutlined iconSize="xl" />}
label={t('Collapse tab content')}
onClick={handleDeleteTopLevelTabs}
@@ -559,6 +563,9 @@ const DashboardBuilder = () => {
const filterBarWidth = dashboardFiltersOpen
? adjustedWidth
: CLOSED_FILTER_BAR_WIDTH;
if (filterBarWidth !== currentFilterBarWidth) {
setCurrentFilterBarWidth(filterBarWidth);
}
return (
<FiltersPanel
width={filterBarWidth}
@@ -591,23 +598,30 @@ const DashboardBuilder = () => {
],
);
const isVerticalFilterBarVisible =
showFilterBar && filterBarOrientation === FilterBarOrientation.Vertical;
const headerFilterBarWidth = isVerticalFilterBarVisible
? currentFilterBarWidth
: 0;
return (
<DashboardWrapper>
{showFilterBar &&
filterBarOrientation === FilterBarOrientation.Vertical && (
<>
<ResizableSidebar
id={`dashboard:${dashboardId}`}
enable={dashboardFiltersOpen}
minWidth={OPEN_FILTER_BAR_WIDTH}
maxWidth={OPEN_FILTER_BAR_MAX_WIDTH}
initialWidth={OPEN_FILTER_BAR_WIDTH}
>
{renderChild}
</ResizableSidebar>
</>
)}
<StyledHeader ref={headerRef}>
{isVerticalFilterBarVisible && (
<ResizableSidebar
id={`dashboard:${dashboardId}`}
enable={dashboardFiltersOpen}
minWidth={OPEN_FILTER_BAR_WIDTH}
maxWidth={OPEN_FILTER_BAR_MAX_WIDTH}
initialWidth={OPEN_FILTER_BAR_WIDTH}
>
{renderChild}
</ResizableSidebar>
)}
<StyledHeader
data-test="dashboard-header-wrapper"
ref={headerRef}
filterBarWidth={headerFilterBarWidth}
>
{/* @ts-ignore */}
<Droppable
data-test="top-level-tabs"
@@ -0,0 +1,389 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, waitFor } from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
import { storeWithState } from 'spec/fixtures/mockStore';
import mockState from 'spec/fixtures/mockState';
import { sliceId } from 'spec/fixtures/mockChartQueries';
import { NativeFilterType } from '@superset-ui/core';
import { CHART_TYPE } from '../../util/componentTypes';
import DashboardContainer from './DashboardContainer';
import * as nativeFiltersActions from '../../actions/nativeFilters';
fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {});
fetchMock.put('glob:*/api/v1/dashboard/*/colors*', {});
fetchMock.post('glob:*/superset/log/?*', {});
jest.mock('@visx/responsive', () => ({
ParentSize: ({
children,
}: {
children: (props: { width: number }) => JSX.Element;
}) => children({ width: 800 }),
}));
jest.mock('src/dashboard/containers/DashboardGrid', () => ({
__esModule: true,
default: () => <div data-test="mock-dashboard-grid" />,
}));
function createTestState(overrides = {}) {
return {
...mockState,
dashboardState: {
...mockState.dashboardState,
sliceIds: [sliceId],
},
dashboardLayout: {
...mockState.dashboardLayout,
present: {
...mockState.dashboardLayout.present,
CHART_ID: {
id: 'CHART_ID',
type: CHART_TYPE,
meta: {
chartId: sliceId,
width: 4,
height: 10,
},
parents: ['ROOT_ID', 'GRID_ID', 'ROW_ID'],
},
},
},
nativeFilters: {
filters: {
'FILTER-1': {
id: 'FILTER-1',
name: 'Test Filter',
filterType: 'filter_select',
targets: [
{
datasetId: 1,
column: { name: 'country' },
},
],
defaultDataMask: {
filterState: { value: null },
},
cascadeParentIds: [],
scope: {
rootPath: ['ROOT_ID'],
excluded: [],
},
controlValues: {},
type: NativeFilterType.NativeFilter,
},
},
},
...overrides,
};
}
function setup(overrideState = {}) {
const initialState = createTestState(overrideState);
return render(<DashboardContainer />, {
useRedux: true,
store: storeWithState(initialState),
});
}
function setupWithStore(overrideState = {}) {
const initialState = createTestState(overrideState);
const store = storeWithState(initialState);
const renderResult = render(<DashboardContainer />, {
useRedux: true,
store,
});
return { store, ...renderResult };
}
let setInScopeStatusMock: jest.SpyInstance;
beforeEach(() => {
setInScopeStatusMock = jest.spyOn(
nativeFiltersActions,
'setInScopeStatusOfFilters',
);
setInScopeStatusMock.mockReturnValue(jest.fn());
});
afterEach(() => {
setInScopeStatusMock.mockRestore();
});
test('calculates chartsInScope correctly for filters', async () => {
setup();
await waitFor(() => {
expect(setInScopeStatusMock).toHaveBeenCalled();
});
expect(setInScopeStatusMock).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
filterId: 'FILTER-1',
chartsInScope: [sliceId],
}),
]),
);
});
test('recalculates chartsInScope when filter non-scope properties change', async () => {
const { store } = setupWithStore();
await waitFor(() => {
expect(setInScopeStatusMock).toHaveBeenCalled();
});
setInScopeStatusMock.mockClear();
// Bug scenario: Editing non-scope properties (e.g., "Sort filter values")
// triggers backend save, but response lacks chartsInScope.
// The fix ensures useEffect recalculates chartsInScope anyway.
const initialState = store.getState();
store.dispatch({
type: 'SET_NATIVE_FILTERS_CONFIG_COMPLETE',
filterChanges: [
{
...initialState.nativeFilters.filters['FILTER-1'],
controlValues: {
...initialState.nativeFilters.filters['FILTER-1'].controlValues,
sortAscending: false,
},
},
],
});
await waitFor(() => {
expect(setInScopeStatusMock).toHaveBeenCalled();
});
});
test('handles multiple filters with different scopes', async () => {
const baseDashboardLayout = mockState.dashboardLayout.present;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { CHART_ID: _removed, ...cleanLayout } = baseDashboardLayout;
const stateWithMultipleFilters = {
...mockState,
dashboardState: {
...mockState.dashboardState,
sliceIds: [18, 19],
},
dashboardLayout: {
...mockState.dashboardLayout,
present: {
...cleanLayout,
CHART_ID_1: {
id: 'CHART_ID_1',
type: CHART_TYPE,
meta: { chartId: 18, width: 4, height: 10 },
parents: ['ROOT_ID', 'GRID_ID', 'ROW_ID'],
},
CHART_ID_2: {
id: 'CHART_ID_2',
type: CHART_TYPE,
meta: { chartId: 19, width: 4, height: 10 },
parents: ['ROOT_ID', 'GRID_ID', 'ROW_ID'],
},
},
},
nativeFilters: {
filters: {
'FILTER-1': {
id: 'FILTER-1',
name: 'Filter 1',
filterType: 'filter_select',
targets: [{ datasetId: 1, column: { name: 'country' } }],
scope: { rootPath: ['ROOT_ID'], excluded: [] },
controlValues: {},
type: NativeFilterType.NativeFilter,
},
'FILTER-2': {
id: 'FILTER-2',
name: 'Filter 2',
filterType: 'filter_select',
targets: [{ datasetId: 1, column: { name: 'region' } }],
scope: { rootPath: ['ROOT_ID'], excluded: [19] },
controlValues: {},
type: NativeFilterType.NativeFilter,
},
},
},
};
setup(stateWithMultipleFilters);
await waitFor(() => {
expect(setInScopeStatusMock).toHaveBeenCalled();
});
expect(setInScopeStatusMock).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
filterId: 'FILTER-1',
chartsInScope: expect.arrayContaining([18, 19]),
}),
expect.objectContaining({
filterId: 'FILTER-2',
chartsInScope: [18],
}),
]),
);
});
test('handles filters with no charts in scope', async () => {
const stateWithExcludedFilter = createTestState({
nativeFilters: {
filters: {
'FILTER-1': {
id: 'FILTER-1',
name: 'Excluded Filter',
filterType: 'filter_select',
targets: [{ datasetId: 1, column: { name: 'country' } }],
scope: {
rootPath: ['ROOT_ID'],
excluded: [sliceId],
},
controlValues: {},
type: NativeFilterType.NativeFilter,
},
},
},
});
setup(stateWithExcludedFilter);
await waitFor(() => {
expect(setInScopeStatusMock).toHaveBeenCalled();
});
expect(setInScopeStatusMock).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
filterId: 'FILTER-1',
chartsInScope: [],
}),
]),
);
});
test('does not dispatch when there are no filters', () => {
const stateWithoutFilters = createTestState({
nativeFilters: {
filters: {},
},
});
setup(stateWithoutFilters);
expect(setInScopeStatusMock).not.toHaveBeenCalled();
});
test('calculates tabsInScope for filters with tab-scoped charts', async () => {
const baseDashboardLayout = mockState.dashboardLayout.present;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { CHART_ID: _removed, ...cleanLayout } = baseDashboardLayout;
const stateWithTabs = {
...mockState,
dashboardState: {
...mockState.dashboardState,
sliceIds: [20, 21, 22],
},
dashboardLayout: {
...mockState.dashboardLayout,
present: {
...cleanLayout,
ROOT_ID: {
id: 'ROOT_ID',
type: 'ROOT',
children: ['TABS-1'],
},
'TABS-1': {
id: 'TABS-1',
type: 'TABS',
children: ['TAB-A', 'TAB-B'],
parents: ['ROOT_ID'],
},
'TAB-A': {
id: 'TAB-A',
type: 'TAB',
children: ['CHART-A1', 'CHART-A2'],
parents: ['ROOT_ID', 'TABS-1'],
meta: { text: 'Tab A' },
},
'TAB-B': {
id: 'TAB-B',
type: 'TAB',
children: ['CHART-B1'],
parents: ['ROOT_ID', 'TABS-1'],
meta: { text: 'Tab B' },
},
'CHART-A1': {
id: 'CHART-A1',
type: CHART_TYPE,
meta: { chartId: 20, width: 4, height: 10 },
parents: ['ROOT_ID', 'TABS-1', 'TAB-A'],
},
'CHART-A2': {
id: 'CHART-A2',
type: CHART_TYPE,
meta: { chartId: 21, width: 4, height: 10 },
parents: ['ROOT_ID', 'TABS-1', 'TAB-A'],
},
'CHART-B1': {
id: 'CHART-B1',
type: CHART_TYPE,
meta: { chartId: 22, width: 4, height: 10 },
parents: ['ROOT_ID', 'TABS-1', 'TAB-B'],
},
},
},
nativeFilters: {
filters: {
'FILTER-TAB-SCOPED': {
id: 'FILTER-TAB-SCOPED',
name: 'Tab Scoped Filter',
filterType: 'filter_select',
targets: [{ datasetId: 1, column: { name: 'region' } }],
scope: { rootPath: ['ROOT_ID'], excluded: [22] },
controlValues: {},
type: NativeFilterType.NativeFilter,
},
},
},
};
setup(stateWithTabs);
await waitFor(() => {
expect(setInScopeStatusMock).toHaveBeenCalled();
});
expect(setInScopeStatusMock).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
filterId: 'FILTER-TAB-SCOPED',
chartsInScope: expect.arrayContaining([20, 21]),
tabsInScope: expect.arrayContaining(['TAB-A']),
}),
]),
);
});
@@ -103,6 +103,9 @@ const TOP_OF_PAGE_RANGE = 220;
const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
const nativeFilterScopes = useNativeFilterScopes();
const nativeFilters = useSelector<RootState, Filters>(
state => state.nativeFilters?.filters,
);
const dispatch = useDispatch();
const dashboardLayout = useSelector<RootState, DashboardLayout>(
@@ -192,7 +195,13 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
};
});
dispatch(setInScopeStatusOfFilters(scopes));
}, [chartIds, JSON.stringify(nativeFilterScopes), dashboardLayout, dispatch]);
}, [
chartIds,
JSON.stringify(nativeFilterScopes),
dashboardLayout,
dispatch,
JSON.stringify(nativeFilters),
]);
const childIds: string[] = useMemo(
() => (topLevelTabs ? topLevelTabs.children : [DASHBOARD_GRID_ID]),
@@ -616,4 +616,115 @@ describe('PropertiesModal', () => {
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Superset Admin');
}, 30000);
test('should not run validation while data is loading', async () => {
mockedIsFeatureEnabled.mockReturnValue(false);
const props = createProps();
// Don't pass dashboardInfo to trigger fetch behavior
const { rerender } = render(<PropertiesModal {...props} show={false} />, {
useRedux: true,
});
const getSpy = jest.spyOn(SupersetCore.SupersetClient, 'get');
let resolveFetch: any;
const fetchPromise = new Promise(resolve => {
resolveFetch = resolve;
});
getSpy.mockReturnValue(fetchPromise as any);
rerender(<PropertiesModal {...props} show />);
// Allow time for validation hooks to run (they shouldn't during loading)
await new Promise(resolve => setTimeout(resolve, 100));
expect(
screen.queryByTestId('dashboard-edit-properties-form'),
).not.toBeInTheDocument();
resolveFetch({
json: {
result: { ...dashboardInfo, json_metadata: mockedJsonMetadata },
},
});
await waitFor(() => {
expect(
screen.getByTestId('dashboard-edit-properties-form'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
});
getSpy.mockRestore();
});
test('should run validation when data is ready', async () => {
mockedIsFeatureEnabled.mockReturnValue(false);
const props = createProps();
// Pass dashboardInfo to skip loading state - data is immediately ready
const propsWithDashboardInfo = {
...props,
dashboardInfo: {
...dashboardInfo,
json_metadata: mockedJsonMetadata,
},
};
render(<PropertiesModal {...propsWithDashboardInfo} />, {
useRedux: true,
});
await waitFor(() => {
expect(
screen.getByTestId('dashboard-edit-properties-form'),
).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
});
test('should trigger validation on field changes when data is ready', async () => {
mockedIsFeatureEnabled.mockReturnValue(false);
const props = createProps();
const propsWithDashboardInfo = {
...props,
dashboardInfo: {
...dashboardInfo,
json_metadata: mockedJsonMetadata,
},
};
render(<PropertiesModal {...propsWithDashboardInfo} />, {
useRedux: true,
});
await waitFor(() => {
expect(
screen.getByTestId('dashboard-edit-properties-form'),
).toBeInTheDocument();
});
const titleInput = screen.getAllByRole('textbox')[0];
// Clear to trigger validation error
userEvent.clear(titleInput);
await waitFor(
() => {
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
},
{ timeout: 1000 },
);
// Re-enter valid title to clear error
userEvent.type(titleInput, 'New Dashboard Title');
await waitFor(
() => {
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
},
{ timeout: 1000 },
);
});
});
@@ -586,20 +586,28 @@ const PropertiesModal = ({
sections: modalSections,
});
// Validate basic section when title changes
useEffect(() => {
validateSection('basic');
}, [dashboardTitle, validateSection]);
const isDataReady = !isLoading && dashboardInfo;
// Validate advanced section when JSON changes
// Validate basic section when title changes or data loads
useEffect(() => {
validateSection('advanced');
}, [jsonMetadata, validateSection]);
if (isDataReady) {
validateSection('basic');
}
}, [dashboardTitle, validateSection, isDataReady]);
// Validate refresh section when refresh frequency changes
// Validate advanced section when JSON changes or data loads
useEffect(() => {
validateSection('refresh');
}, [refreshFrequency, validateSection]);
if (isDataReady) {
validateSection('advanced');
}
}, [jsonMetadata, validateSection, isDataReady]);
// Validate refresh section when frequency changes or data loads
useEffect(() => {
if (isDataReady) {
validateSection('refresh');
}
}, [refreshFrequency, validateSection, isDataReady]);
return (
<StandardModal
@@ -630,7 +638,9 @@ const PropertiesModal = ({
onFinish={onFinish}
onFieldsChange={() => {
// Re-validate sections when form fields change
setTimeout(() => validateSection('basic'), 100);
if (isDataReady) {
validateSection('basic');
}
}}
data-test="dashboard-edit-properties-form"
layout="vertical"
@@ -152,7 +152,6 @@ const ActionButtons = ({
<Button
disabled={!isClearAllEnabled}
buttonStyle="link"
buttonSize="small"
className="filter-clear-all-button"
onClick={onClearAll}
{...getFilterBarTestId('clear-button')}
@@ -53,6 +53,7 @@ const VerticalFilterControlTitle = styled.h4`
const HorizontalFilterControlTitle = styled(VerticalFilterControlTitle)`
font-weight: ${({ theme }) => theme.fontWeightNormal};
color: ${({ theme }) => theme.colorText};
margin: 0;
${truncationCSS};
`;
@@ -60,6 +61,7 @@ const HorizontalOverflowFilterControlTitle = styled(
HorizontalFilterControlTitle,
)`
max-width: none;
margin: ${({ theme }) => `${theme.sizeUnit * 2}px 0 ${theme.sizeUnit}px`};
`;
const VerticalFilterControlTitleBox = styled.div`
@@ -162,7 +164,7 @@ const HorizontalFormItem = styled(StyledFormItem)<{
align-items: center;
}
.ant-form-item-label {
&& > .ant-row > .ant-form-item-label {
display: flex;
align-items: center;
overflow: visible;
@@ -62,7 +62,15 @@ import { useFilterOutlined } from '../useFilterOutlined';
const HEIGHT = 32;
// Overrides superset-ui height with min-height
const StyledDiv = styled.div`
const StyledDiv = styled.div<{
orientation: FilterBarOrientation;
overflow: boolean;
}>`
padding-bottom: ${({ theme, orientation, overflow }) =>
orientation === FilterBarOrientation.Horizontal && !overflow
? 0
: (theme?.sizeUnit ?? 4)}px;
& > div {
height: auto !important;
min-height: ${HEIGHT}px;
@@ -353,7 +361,11 @@ const FilterValue: FC<FilterControlProps> = ({
}
return (
<StyledDiv data-test="form-item-value">
<StyledDiv
data-test="form-item-value"
orientation={orientation}
overflow={overflow}
>
{isLoading ? (
<Loading position="inline-centered" size="s" muted />
) : (
@@ -184,9 +184,6 @@ afterEach(() => {
jest.restoreAllMocks();
});
// Set timeout for all tests in this file to prevent CI timeouts
jest.setTimeout(60000);
function defaultRender(initialState: any = defaultState(), modalProps = props) {
return render(<FiltersConfigModal {...modalProps} />, {
initialState,
@@ -226,9 +223,10 @@ test('renders a value filter type', () => {
test('renders a numerical range filter type', async () => {
defaultRender();
userEvent.click(screen.getByText(VALUE_REGEX));
await userEvent.click(screen.getByText(VALUE_REGEX));
await waitFor(() => userEvent.click(screen.getByText(NUMERICAL_RANGE_REGEX)));
const numericalRangeOption = await screen.findByText(NUMERICAL_RANGE_REGEX);
await userEvent.click(numericalRangeOption);
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
@@ -250,9 +248,10 @@ test('renders a numerical range filter type', async () => {
test('renders a time range filter type', async () => {
defaultRender();
userEvent.click(screen.getByText(VALUE_REGEX));
await userEvent.click(screen.getByText(VALUE_REGEX));
await waitFor(() => userEvent.click(screen.getByText(TIME_RANGE_REGEX)));
const timeRangeOption = await screen.findByText(TIME_RANGE_REGEX);
await userEvent.click(timeRangeOption);
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
@@ -265,9 +264,10 @@ test('renders a time range filter type', async () => {
test('renders a time column filter type', async () => {
defaultRender();
userEvent.click(screen.getByText(VALUE_REGEX));
await userEvent.click(screen.getByText(VALUE_REGEX));
await waitFor(() => userEvent.click(screen.getByText(TIME_COLUMN_REGEX)));
const timeColumnOption = await screen.findByText(TIME_COLUMN_REGEX);
await userEvent.click(timeColumnOption);
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
@@ -280,9 +280,10 @@ test('renders a time column filter type', async () => {
test('renders a time grain filter type', async () => {
defaultRender();
userEvent.click(screen.getByText(VALUE_REGEX));
await userEvent.click(screen.getByText(VALUE_REGEX));
await waitFor(() => userEvent.click(screen.getByText(TIME_GRAIN_REGEX)));
const timeGrainOption = await screen.findByText(TIME_GRAIN_REGEX);
await userEvent.click(timeGrainOption);
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
@@ -295,7 +296,7 @@ test('renders a time grain filter type', async () => {
test('render time filter types as disabled if there are no temporal columns in the dataset', async () => {
defaultRender(noTemporalColumnsState());
userEvent.click(screen.getByText(VALUE_REGEX));
await userEvent.click(screen.getByText(VALUE_REGEX));
const timeRange = await screen.findByText(TIME_RANGE_REGEX);
const timeGrain = await screen.findByText(TIME_GRAIN_REGEX);
@@ -309,7 +310,7 @@ test('render time filter types as disabled if there are no temporal columns in t
test('validates the name', async () => {
defaultRender();
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
await waitFor(
async () => {
expect(await screen.findByText(NAME_REQUIRED_REGEX)).toBeInTheDocument();
@@ -320,7 +321,7 @@ test('validates the name', async () => {
test('validates the column', async () => {
defaultRender();
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
expect(await screen.findByText(COLUMN_REQUIRED_REGEX)).toBeInTheDocument();
});
@@ -328,8 +329,8 @@ test('validates the column', async () => {
test.skip('validates the default value', async () => {
defaultRender(noTemporalColumnsState());
expect(await screen.findByText('birth_names')).toBeInTheDocument();
userEvent.type(screen.getByRole('combobox'), `Column A{Enter}`);
userEvent.click(getCheckbox(DEFAULT_VALUE_REGEX));
await userEvent.type(screen.getByRole('combobox'), `Column A{Enter}`);
await userEvent.click(getCheckbox(DEFAULT_VALUE_REGEX));
await waitFor(() => {
expect(
screen.queryByText(FILL_REQUIRED_FIELDS_REGEX),
@@ -345,21 +346,24 @@ test('validates the pre-filter value', async () => {
try {
defaultRender();
userEvent.click(screen.getByText(FILTER_SETTINGS_REGEX));
userEvent.click(getCheckbox(PRE_FILTER_REGEX));
await userEvent.click(screen.getByText(FILTER_SETTINGS_REGEX));
await userEvent.click(getCheckbox(PRE_FILTER_REGEX));
jest.runAllTimers();
await waitFor(() => {
const errorMessages = screen.getAllByText(PRE_FILTER_REQUIRED_REGEX);
expect(errorMessages.length).toBeGreaterThan(0);
});
} finally {
jest.useRealTimers();
}
jest.runOnlyPendingTimers();
jest.useRealTimers();
// Wait for validation to complete after timer switch
await waitFor(
() => {
expect(screen.getByText(PRE_FILTER_REQUIRED_REGEX)).toBeInTheDocument();
const errorMessages = screen.queryAllByText(PRE_FILTER_REQUIRED_REGEX);
expect(errorMessages.length).toBeGreaterThan(0);
},
{ timeout: 15000 },
);
@@ -368,13 +372,13 @@ test('validates the pre-filter value', async () => {
// eslint-disable-next-line jest/no-disabled-tests
test.skip("doesn't render time range pre-filter if there are no temporal columns in datasource", async () => {
defaultRender(noTemporalColumnsState());
userEvent.click(screen.getByText(DATASET_REGEX));
await waitFor(() => {
await userEvent.click(screen.getByText(DATASET_REGEX));
await waitFor(async () => {
expect(screen.queryByLabelText('Loading')).not.toBeInTheDocument();
userEvent.click(screen.getByText('birth_names'));
await userEvent.click(screen.getByText('birth_names'));
});
userEvent.click(screen.getByText(FILTER_SETTINGS_REGEX));
userEvent.click(getCheckbox(PRE_FILTER_REGEX));
await userEvent.click(screen.getByText(FILTER_SETTINGS_REGEX));
await userEvent.click(getCheckbox(PRE_FILTER_REGEX));
await waitFor(() =>
expect(
screen.queryByText(TIME_RANGE_PREFILTER_REGEX),
@@ -439,9 +443,9 @@ test('deletes a filter', async () => {
const removeButtons = screen.getAllByRole('button', {
name: 'delete',
});
userEvent.click(removeButtons[2]);
await userEvent.click(removeButtons[2]);
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
await waitFor(() =>
expect(onSave).toHaveBeenCalledWith(
@@ -476,8 +480,8 @@ test('deletes a filter including dependencies', async () => {
const removeButtons = screen.getAllByRole('button', {
name: 'delete',
});
userEvent.click(removeButtons[1]);
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
await userEvent.click(removeButtons[1]);
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
await waitFor(() =>
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
@@ -525,7 +529,7 @@ test('switches the order between two filters', async () => {
fireEvent.dragEnd(draggableFilters[0]);
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
await waitFor(() =>
expect(onSave).toHaveBeenCalledWith(
@@ -568,14 +572,14 @@ test('rearranges three filters and deletes one of them', async () => {
const deleteButtons = screen.getAllByRole('button', {
name: 'delete',
});
userEvent.click(deleteButtons[1]);
await userEvent.click(deleteButtons[1]);
fireEvent.dragStart(draggableFilters[0]);
fireEvent.dragOver(draggableFilters[2]);
fireEvent.drop(draggableFilters[2]);
fireEvent.dragEnd(draggableFilters[0]);
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
await waitFor(() =>
expect(onSave).toHaveBeenCalledWith(
@@ -594,47 +598,51 @@ test('rearranges three filters and deletes one of them', async () => {
test('modifies the name of a filter', async () => {
jest.useFakeTimers();
const nativeFilterState = [
buildNativeFilter('NATIVE_FILTER-1', 'state', []),
buildNativeFilter('NATIVE_FILTER-2', 'country', []),
];
try {
const nativeFilterState = [
buildNativeFilter('NATIVE_FILTER-1', 'state', []),
buildNativeFilter('NATIVE_FILTER-2', 'country', []),
];
const state = {
...defaultState(),
dashboardInfo: {
metadata: { native_filter_configuration: nativeFilterState },
},
dashboardLayout,
};
const state = {
...defaultState(),
dashboardInfo: {
metadata: { native_filter_configuration: nativeFilterState },
},
dashboardLayout,
};
const onSave = jest.fn();
const onSave = jest.fn();
defaultRender(state, {
...props,
createNewOnOpen: false,
onSave,
});
defaultRender(state, {
...props,
createNewOnOpen: false,
onSave,
});
const filterNameInput = screen.getByRole('textbox', {
name: FILTER_NAME_REGEX,
});
const filterNameInput = screen.getByRole('textbox', {
name: FILTER_NAME_REGEX,
});
userEvent.clear(filterNameInput);
userEvent.type(filterNameInput, 'New Filter Name');
await userEvent.clear(filterNameInput);
await userEvent.type(filterNameInput, 'New Filter Name');
jest.runAllTimers();
jest.runAllTimers();
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
await waitFor(() =>
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
modified: expect.arrayContaining([
expect.objectContaining({ name: 'New Filter Name' }),
]),
}),
),
);
await waitFor(() =>
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
modified: expect.arrayContaining([
expect.objectContaining({ name: 'New Filter Name' }),
]),
}),
),
);
} finally {
jest.useRealTimers();
}
});
test('renders a filter with a chart containing BigInt values', async () => {
@@ -39,90 +39,260 @@ beforeEach(() => {
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('useIsFilterInScope', () => {
test('should return true for dividers (always in scope)', () => {
const divider: Divider = {
id: 'divider_1',
type: NativeFilterType.Divider,
title: 'Sample Divider',
description: 'Divider description',
};
test('useIsFilterInScope should return true for dividers (always in scope)', () => {
const divider: Divider = {
id: 'divider_1',
type: NativeFilterType.Divider,
title: 'Sample Divider',
description: 'Divider description',
};
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(divider)).toBe(true);
});
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(divider)).toBe(true);
});
test('should return true for filters with charts in active tabs', () => {
const filter: Filter = {
test('useIsFilterInScope should return true for filters with charts in active tabs', () => {
const filter: Filter = {
id: 'filter_1',
name: 'Test Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [123],
scope: { rootPath: ['TAB_1'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
description: 'Sample filter description',
};
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(true);
});
test('useIsFilterInScope should return false for filters with inactive rootPath', () => {
const filter: Filter = {
id: 'filter_3',
name: 'Test Filter 3',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
scope: { rootPath: ['TAB_99'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 3 }],
description: 'Sample filter description',
};
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(false);
});
test('useSelectFiltersInScope should return all filters in scope when no tabs exist', () => {
const filters: Filter[] = [
{
id: 'filter_1',
name: 'Test Filter',
name: 'Filter 1',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [123],
scope: { rootPath: ['TAB_1'], excluded: [] },
scope: { rootPath: [], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
description: 'Sample filter description',
};
},
{
id: 'filter_2',
name: 'Filter 2',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
scope: { rootPath: [], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 2 }],
description: 'Sample filter description',
},
];
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(true);
const { result } = renderHook(() => useSelectFiltersInScope(filters));
expect(result.current[0]).toEqual(filters);
expect(result.current[1]).toEqual([]);
});
// Tests for filter scope persistence when chartsInScope is missing
// (Bug fix: filters incorrectly marked out of scope after editing non-scope properties)
test('filter without chartsInScope should fall back to rootPath check', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: ['TAB_1'] },
dashboardLayout: { present: {} },
};
return selector(mockState);
});
test('should return false for filters with inactive rootPath', () => {
const filter: Filter = {
id: 'filter_3',
name: 'Test Filter 3',
const filter: Filter = {
id: 'filter_fallback',
name: 'Filter Without Charts',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
scope: { rootPath: ['TAB_1'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
description: 'Filter with missing chartsInScope',
};
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(true);
});
test('filter with empty chartsInScope array should check rootPath', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: ['TAB_1'] },
dashboardLayout: { present: {} },
};
return selector(mockState);
});
const filter: Filter = {
id: 'filter_empty_charts',
name: 'Filter With Empty Charts',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [],
scope: { rootPath: ['TAB_1'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
description: 'Filter with empty chartsInScope',
};
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(true);
});
test('filter without chartsInScope and inactive rootPath should be out of scope', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: ['TAB_1'] },
dashboardLayout: { present: {} },
};
return selector(mockState);
});
const filter: Filter = {
id: 'filter_inactive',
name: 'Inactive Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
scope: { rootPath: ['INACTIVE_TAB'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
description: 'Filter in inactive tab',
};
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(false);
});
test('filter with ROOT_ID in rootPath should be in scope when chartsInScope is missing', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: ['ROOT_ID'] },
dashboardLayout: { present: {} },
};
return selector(mockState);
});
const filter: Filter = {
id: 'filter_root',
name: 'Global Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
scope: { rootPath: ['ROOT_ID'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
description: 'Global filter with missing chartsInScope',
};
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(true);
});
test('useSelectFiltersInScope correctly categorizes filters with missing chartsInScope', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: ['TAB_1'] },
dashboardLayout: {
present: {
tab1: { type: 'TAB', id: 'TAB_1' },
},
},
};
return selector(mockState);
});
const filters: Filter[] = [
{
id: 'filter_in_scope',
name: 'In Scope Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
scope: { rootPath: ['TAB_1'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
description: 'Filter that should be in scope',
},
{
id: 'filter_out_scope',
name: 'Out of Scope Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
scope: { rootPath: ['TAB_99'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 3 }],
description: 'Sample filter description',
};
targets: [{ column: { name: 'column_name' }, datasetId: 2 }],
description: 'Filter that should be out of scope',
},
];
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(false);
});
const { result } = renderHook(() => useSelectFiltersInScope(filters));
expect(result.current[0]).toContainEqual(
expect.objectContaining({ id: 'filter_in_scope' }),
);
expect(result.current[1]).toContainEqual(
expect.objectContaining({ id: 'filter_out_scope' }),
);
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('useSelectFiltersInScope', () => {
test('should return all filters in scope when no tabs exist', () => {
const filters: Filter[] = [
{
id: 'filter_1',
name: 'Filter 1',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
scope: { rootPath: [], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
description: 'Sample filter description',
},
{
id: 'filter_2',
name: 'Filter 2',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
scope: { rootPath: [], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 2 }],
description: 'Sample filter description',
},
];
test('filter with chartsInScope takes precedence over rootPath', () => {
const filter: Filter = {
id: 'filter_priority',
name: 'Priority Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [123, 456],
scope: { rootPath: ['INACTIVE_TAB'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
description: 'Filter with chartsInScope should ignore rootPath',
};
const { result } = renderHook(() => useSelectFiltersInScope(filters));
expect(result.current[0]).toEqual(filters);
expect(result.current[1]).toEqual([]);
});
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(true);
});
@@ -153,7 +153,7 @@ export const useDownloadScreenshot = (
anchor,
activeTabs,
dataMask,
urlParams: getDashboardUrlParams(['edit']),
urlParams: getDashboardUrlParams(),
},
})
.then(({ json }) => {
@@ -0,0 +1,162 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Filter, NativeFilterType } from '@superset-ui/core';
import nativeFilterReducer from './nativeFilters';
import { SET_NATIVE_FILTERS_CONFIG_COMPLETE } from '../actions/nativeFilters';
const createMockFilter = (
id: string,
chartsInScope?: number[],
tabsInScope?: string[],
): Filter => ({
cascadeParentIds: [],
id,
name: `Filter ${id}`,
filterType: 'filter_select',
targets: [
{
datasetId: 0,
column: {
name: 'test column',
displayName: 'test column',
},
},
],
defaultDataMask: {
filterState: {
value: null,
},
},
scope: {
rootPath: [],
excluded: [],
},
controlValues: {
allowsMultipleValues: true,
isRequired: false,
},
type: NativeFilterType.NativeFilter,
description: '',
chartsInScope,
tabsInScope,
});
test('SET_NATIVE_FILTERS_CONFIG_COMPLETE updates filters with complete scope properties', () => {
const initialState = {
filters: {
filter1: createMockFilter('filter1', [1, 2], ['tab1']),
},
};
const action = {
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE as typeof SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: [createMockFilter('filter1', [3, 4], ['tab2'])],
};
const result = nativeFilterReducer(initialState, action);
expect(result.filters.filter1.chartsInScope).toEqual([3, 4]);
expect(result.filters.filter1.tabsInScope).toEqual(['tab2']);
});
test('SET_NATIVE_FILTERS_CONFIG_COMPLETE preserves existing chartsInScope when missing from update', () => {
const initialState = {
filters: {
filter1: createMockFilter('filter1', [1, 2, 3], ['tab1']),
},
};
const filterWithoutChartsInScope: Filter = {
...createMockFilter('filter1', undefined, ['tab2']),
chartsInScope: undefined,
};
const action = {
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE as typeof SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: [filterWithoutChartsInScope],
};
const result = nativeFilterReducer(initialState, action);
expect(result.filters.filter1.chartsInScope).toEqual([1, 2, 3]);
expect(result.filters.filter1.tabsInScope).toEqual(['tab2']);
});
test('SET_NATIVE_FILTERS_CONFIG_COMPLETE preserves existing tabsInScope when missing from update', () => {
const initialState = {
filters: {
filter1: createMockFilter('filter1', [1, 2], ['tab1', 'tab2']),
},
};
const filterWithoutTabsInScope: Filter = {
...createMockFilter('filter1', [3, 4], undefined),
tabsInScope: undefined,
};
const action = {
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE as typeof SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: [filterWithoutTabsInScope],
};
const result = nativeFilterReducer(initialState, action);
expect(result.filters.filter1.chartsInScope).toEqual([3, 4]);
expect(result.filters.filter1.tabsInScope).toEqual(['tab1', 'tab2']);
});
test('SET_NATIVE_FILTERS_CONFIG_COMPLETE handles undefined scope properties for new filters', () => {
const initialState = {
filters: {},
};
const filterWithUndefinedScopes: Filter = {
...createMockFilter('filter1', undefined, undefined),
chartsInScope: undefined,
tabsInScope: undefined,
};
const action = {
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE as typeof SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: [filterWithUndefinedScopes],
};
const result = nativeFilterReducer(initialState, action);
expect(result.filters.filter1.chartsInScope).toBeUndefined();
expect(result.filters.filter1.tabsInScope).toBeUndefined();
});
test('SET_NATIVE_FILTERS_CONFIG_COMPLETE treats empty arrays as valid scope properties', () => {
const initialState = {
filters: {
filter1: createMockFilter('filter1', [1, 2, 3], ['tab1', 'tab2']),
},
};
const action = {
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE as typeof SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: [createMockFilter('filter1', [], [])],
};
const result = nativeFilterReducer(initialState, action);
expect(result.filters.filter1.chartsInScope).toEqual([]);
expect(result.filters.filter1.tabsInScope).toEqual([]);
});
@@ -69,7 +69,16 @@ function handleFilterChangesComplete(
) {
const modifiedFilters = { ...state.filters };
filters.forEach(filter => {
modifiedFilters[filter.id] = filter;
if (filter.chartsInScope != null && filter.tabsInScope != null) {
modifiedFilters[filter.id] = filter;
} else {
const existingFilter = modifiedFilters[filter.id];
modifiedFilters[filter.id] = {
...filter,
chartsInScope: filter.chartsInScope ?? existingFilter?.chartsInScope,
tabsInScope: filter.tabsInScope ?? existingFilter?.tabsInScope,
};
}
});
return {
@@ -153,12 +153,6 @@ test('Should render null when show:false', async () => {
});
});
// Add cleanup after each test
afterEach(async () => {
// Wait for any pending effects to complete
await new Promise(resolve => setTimeout(resolve, 0));
});
test('Should render when show:true', async () => {
const props = createProps();
renderModal(props);
@@ -193,7 +187,7 @@ test('"Close" button should call "onHide"', async () => {
expect(props.onHide).toHaveBeenCalledTimes(0);
});
userEvent.click(screen.getByRole('button', { name: 'Close' }));
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
await waitFor(() => {
expect(props.onHide).toHaveBeenCalledTimes(1);
@@ -254,7 +248,7 @@ test('"Cancel" button should call "onHide"', async () => {
expect(props.onHide).toHaveBeenCalledTimes(0);
});
userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => {
expect(props.onHide).toHaveBeenCalledTimes(1);
@@ -262,7 +256,7 @@ test('"Cancel" button should call "onHide"', async () => {
});
});
test('"Save" button should call only "onSave"', async () => {
test('"Save" button should call "onSave" and "onHide"', async () => {
const props = createProps();
renderModal(props);
await waitFor(() => {
@@ -271,7 +265,7 @@ test('"Save" button should call only "onSave"', async () => {
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
});
userEvent.click(screen.getByRole('button', { name: 'Save' }));
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(props.onSave).toHaveBeenCalledTimes(1);
@@ -293,7 +287,7 @@ test('Empty "Certified by" should clear "Certification details"', async () => {
// Expand the Advanced section first to access certification details
const advancedPanel = screen.getByText('Advanced').closest('[role="tab"]');
if (advancedPanel) {
userEvent.click(advancedPanel);
await userEvent.click(advancedPanel);
}
await waitFor(() => {
@@ -312,11 +306,11 @@ test('"Name" should not be empty', async () => {
const name = screen.getByRole('textbox', { name: 'Name' });
userEvent.clear(name);
await userEvent.clear(name);
expect(name).toHaveValue('');
userEvent.click(screen.getByRole('button', { name: 'Save' }));
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(props.onSave).toHaveBeenCalledTimes(0);
@@ -329,12 +323,12 @@ test('"Name" should not be empty when saved', async () => {
const name = screen.getByRole('textbox', { name: 'Name' });
userEvent.clear(name);
userEvent.type(name, 'Test chart new name');
await userEvent.clear(name);
await userEvent.type(name, 'Test chart new name');
expect(name).toHaveValue('Test chart new name');
userEvent.click(screen.getByRole('button', { name: 'Save' }));
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(props.onSave).toHaveBeenCalledTimes(1);
@@ -351,19 +345,17 @@ test('"Cache timeout" should not be empty when saved', async () => {
// Expand the Configuration section first to access cache timeout
const configPanel = screen.getByText('Configuration').closest('[role="tab"]');
if (configPanel) {
userEvent.click(configPanel);
await userEvent.click(configPanel);
}
await waitFor(() => {
const cacheTimeout = screen.getByRole('textbox', { name: 'Cache timeout' });
userEvent.clear(cacheTimeout);
userEvent.type(cacheTimeout, '1000');
expect(cacheTimeout).toHaveValue('1000');
const cacheTimeout = await screen.findByRole('textbox', {
name: 'Cache timeout',
});
await userEvent.clear(cacheTimeout);
await userEvent.type(cacheTimeout, '1000');
expect(cacheTimeout).toHaveValue('1000');
userEvent.click(screen.getByRole('button', { name: 'Save' }));
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(props.onSave).toHaveBeenCalledTimes(1);
@@ -386,12 +378,12 @@ test('"Description" should not be empty when saved', async () => {
const textboxes = screen.getAllByRole('textbox');
const description = textboxes[1]; // Description is the textarea
userEvent.clear(description);
userEvent.type(description, 'Test description');
await userEvent.clear(description);
await userEvent.type(description, 'Test description');
expect(description).toHaveValue('Test description');
userEvent.click(screen.getByRole('button', { name: 'Save' }));
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(props.onSave).toHaveBeenCalledTimes(1);
@@ -408,19 +400,17 @@ test('"Certified by" should not be empty when saved', async () => {
// Expand the Advanced section first to access certified by
const advancedPanel = screen.getByText('Advanced').closest('[role="tab"]');
if (advancedPanel) {
userEvent.click(advancedPanel);
await userEvent.click(advancedPanel);
}
await waitFor(() => {
const certifiedBy = screen.getByRole('textbox', { name: 'Certified by' });
userEvent.clear(certifiedBy);
userEvent.type(certifiedBy, 'Test certified by');
expect(certifiedBy).toHaveValue('Test certified by');
const certifiedBy = await screen.findByRole('textbox', {
name: 'Certified by',
});
await userEvent.clear(certifiedBy);
await userEvent.type(certifiedBy, 'Test certified by');
expect(certifiedBy).toHaveValue('Test certified by');
userEvent.click(screen.getByRole('button', { name: 'Save' }));
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(props.onSave).toHaveBeenCalledTimes(1);
@@ -437,21 +427,17 @@ test('"Certification details" should not be empty when saved', async () => {
// Expand the Advanced section first to access certification details
const advancedPanel = screen.getByText('Advanced').closest('[role="tab"]');
if (advancedPanel) {
userEvent.click(advancedPanel);
await userEvent.click(advancedPanel);
}
await waitFor(() => {
const certificationDetails = screen.getByRole('textbox', {
name: 'Certification details',
});
userEvent.clear(certificationDetails);
userEvent.type(certificationDetails, 'Test certification details');
expect(certificationDetails).toHaveValue('Test certification details');
const certificationDetails = await screen.findByRole('textbox', {
name: 'Certification details',
});
await userEvent.clear(certificationDetails);
await userEvent.type(certificationDetails, 'Test certification details');
expect(certificationDetails).toHaveValue('Test certification details');
userEvent.click(screen.getByRole('button', { name: 'Save' }));
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(props.onSave).toHaveBeenCalledTimes(1);
@@ -474,18 +460,14 @@ test('Should display only custom tags when tagging system is enabled', async ()
const props = createProps();
renderModal(props);
await waitFor(async () => {
expect(await screen.findByText('Tags')).toBeInTheDocument();
expect(
await screen.findByRole('combobox', { name: 'Tags' }),
).toBeInTheDocument();
});
expect(await screen.findByText('Tags')).toBeInTheDocument();
expect(
await screen.findByRole('combobox', { name: 'Tags' }),
).toBeInTheDocument();
await waitFor(async () => {
expect(await screen.findByText('my test tag')).toBeInTheDocument();
expect(screen.queryByText('type:chart')).not.toBeInTheDocument();
expect(screen.queryByText('owner:1')).not.toBeInTheDocument();
});
expect(await screen.findByText('my test tag')).toBeInTheDocument();
expect(screen.queryByText('type:chart')).not.toBeInTheDocument();
expect(screen.queryByText('owner:1')).not.toBeInTheDocument();
mockIsFeatureEnabled.mockRestore();
});
@@ -0,0 +1,108 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { screen, render, waitFor } from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
import ViewQueryModal from './ViewQueryModal';
const mockFormData = {
datasource: '1__table',
viz_type: 'table',
};
const chartDataEndpoint = 'glob:*/api/v1/chart/data*';
afterEach(() => {
jest.resetAllMocks();
fetchMock.restore();
});
test('renders Alert component when query result contains validation error', async () => {
/**
* Regression test for issue #35492 - Phase 1
* Verifies that validation errors from the backend are displayed in an Alert
* component instead of showing a blank panel
*/
// Mock API response with validation error
fetchMock.post(chartDataEndpoint, {
result: [
{
error: 'Missing temporal column',
language: 'sql',
},
],
});
render(<ViewQueryModal latestQueryFormData={mockFormData} />, {
useRedux: true,
});
// Wait for API call to complete
await waitFor(() =>
expect(fetchMock.calls(chartDataEndpoint)).toHaveLength(1),
);
// Assert Alert component is rendered with error message
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText('Missing temporal column')).toBeInTheDocument();
});
test('renders both Alert and SQL query when parsing error occurs', async () => {
/**
* Regression test for issue #35492 - Phase 2
* Verifies that parsing errors (which occur after SQL generation) display
* both the error message AND the SQL query that failed to parse.
*
* This differs from validation errors (Phase 1) where no SQL was generated.
* For parsing errors, the SQL was successfully compiled but optimization failed.
*/
// Mock API response with parsing error (has both query and error)
fetchMock.post(chartDataEndpoint, {
result: [
{
query: 'SELECT SUM ( Open',
error: "Error parsing near 'Open' at line 1:17",
language: 'sql',
},
],
});
render(<ViewQueryModal latestQueryFormData={mockFormData} />, {
useRedux: true,
});
// Wait for the error message to appear
await waitFor(() =>
expect(
screen.getByText("Error parsing near 'Open' at line 1:17"),
).toBeInTheDocument(),
);
// Assert Alert component is rendered with error message
expect(screen.getByRole('alert')).toBeInTheDocument();
// Assert SQL query is also displayed
// Note: The SQL is rendered inside a syntax-highlighted code block where
// each keyword is in a separate span element
await waitFor(() => {
expect(screen.getByText('SELECT')).toBeInTheDocument();
expect(screen.getByText('SUM')).toBeInTheDocument();
expect(screen.getByText('Open')).toBeInTheDocument();
});
});
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { FC, useEffect, useState } from 'react';
import { FC, Fragment, useEffect, useState } from 'react';
import {
ensureIsArray,
@@ -24,8 +24,9 @@ import {
getClientErrorObject,
QueryFormData,
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/ui';
import { styled, Alert } from '@apache-superset/core/ui';
import { Loading } from '@superset-ui/core/components';
import { SupportedLanguage } from '@superset-ui/core/components/CodeSyntaxHighlighter';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
import ViewQuery from 'src/explore/components/controls/ViewQuery';
@@ -34,8 +35,9 @@ interface Props {
}
type Result = {
query: string;
language: string;
query?: string;
language: SupportedLanguage;
error?: string;
};
const ViewQueryModalContainer = styled.div`
@@ -87,16 +89,21 @@ const ViewQueryModal: FC<Props> = ({ latestQueryFormData }) => {
return (
<ViewQueryModalContainer>
{result.map((item, index) =>
item.query ? (
<ViewQuery
key={`query-${index}`}
datasource={latestQueryFormData.datasource}
sql={item.query}
language="sql"
/>
) : null,
)}
{result.map((item, index) => (
// Static API response data - index is appropriate for keys
<Fragment key={index}>
{item.error && (
<Alert type="error" message={item.error} closable={false} />
)}
{item.query && (
<ViewQuery
datasource={latestQueryFormData.datasource}
sql={item.query}
language={item.language}
/>
)}
</Fragment>
))}
</ViewQueryModalContainer>
);
};
@@ -379,4 +379,300 @@ describe('SelectFilterPlugin', () => {
userEvent.type(screen.getByRole('combobox'), 'new value');
expect(await screen.findByTitle('new value')).toBeInTheDocument();
});
test('preserves backend order when sortMetric is specified', () => {
const testData = [
{ gender: 'zebra' },
{ gender: 'alpha' },
{ gender: 'beta' },
];
const testProps = {
...selectMultipleProps,
formData: {
...selectMultipleProps.formData,
sortMetric: 'count',
sortAscending: true,
},
queriesData: [
{
rowcount: 3,
colnames: ['gender'],
coltypes: [1],
data: testData,
applied_filters: [{ column: 'gender' }],
rejected_filters: [],
},
],
filterState: {
value: [],
label: '',
excludeFilterValues: true,
},
};
render(
// @ts-ignore
<SelectFilterPlugin
// @ts-ignore
{...transformProps(testProps)}
setDataMask={jest.fn()}
showOverflow={false}
/>,
{
useRedux: true,
initialState: {
nativeFilters: {
filters: {
'test-filter': {
name: 'Test Filter',
},
},
},
dataMask: {
'test-filter': {
extraFormData: {},
filterState: {
value: [],
label: '',
excludeFilterValues: true,
},
},
},
},
},
);
const filterSelect = screen.getAllByRole('combobox')[0];
userEvent.click(filterSelect);
// When sortMetric is specified, options should appear in the original data order
// (zebra, alpha, beta) not alphabetically sorted
const options = screen.getAllByRole('option');
expect(options[0]).toHaveTextContent('zebra');
expect(options[1]).toHaveTextContent('alpha');
expect(options[2]).toHaveTextContent('beta');
});
test('applies alphabetical sorting when sortMetric is not specified', () => {
const testData = [
{ gender: 'zebra' },
{ gender: 'alpha' },
{ gender: 'beta' },
];
const testProps = {
...selectMultipleProps,
formData: {
...selectMultipleProps.formData,
sortMetric: undefined,
sortAscending: true,
},
queriesData: [
{
rowcount: 3,
colnames: ['gender'],
coltypes: [1],
data: testData,
applied_filters: [{ column: 'gender' }],
rejected_filters: [],
},
],
filterState: {
value: [],
label: '',
excludeFilterValues: true,
},
};
render(
// @ts-ignore
<SelectFilterPlugin
// @ts-ignore
{...transformProps(testProps)}
setDataMask={jest.fn()}
showOverflow={false}
/>,
{
useRedux: true,
initialState: {
nativeFilters: {
filters: {
'test-filter': {
name: 'Test Filter',
},
},
},
dataMask: {
'test-filter': {
extraFormData: {},
filterState: {
value: [],
label: '',
excludeFilterValues: true,
},
},
},
},
},
);
const filterSelect = screen.getAllByRole('combobox')[0];
userEvent.click(filterSelect);
// When sortMetric is not specified, options should be sorted alphabetically
// (alpha, beta, zebra)
const options = screen.getAllByRole('option');
expect(options[0]).toHaveTextContent('alpha');
expect(options[1]).toHaveTextContent('beta');
expect(options[2]).toHaveTextContent('zebra');
});
test('applies descending alphabetical sorting when sortAscending is false and no sortMetric', () => {
const testData = [
{ gender: 'zebra' },
{ gender: 'alpha' },
{ gender: 'beta' },
];
const testProps = {
...selectMultipleProps,
formData: {
...selectMultipleProps.formData,
sortMetric: undefined,
sortAscending: false,
},
queriesData: [
{
rowcount: 3,
colnames: ['gender'],
coltypes: [1],
data: testData,
applied_filters: [{ column: 'gender' }],
rejected_filters: [],
},
],
filterState: {
value: [],
label: '',
excludeFilterValues: true,
},
};
render(
// @ts-ignore
<SelectFilterPlugin
// @ts-ignore
{...transformProps(testProps)}
setDataMask={jest.fn()}
showOverflow={false}
/>,
{
useRedux: true,
initialState: {
nativeFilters: {
filters: {
'test-filter': {
name: 'Test Filter',
},
},
},
dataMask: {
'test-filter': {
extraFormData: {},
filterState: {
value: [],
label: '',
excludeFilterValues: true,
},
},
},
},
},
);
const filterSelect = screen.getAllByRole('combobox')[0];
userEvent.click(filterSelect);
// When sortAscending is false and no sortMetric, options should be sorted
// in descending alphabetical order (zebra, beta, alpha)
const options = screen.getAllByRole('option');
expect(options[0]).toHaveTextContent('zebra');
expect(options[1]).toHaveTextContent('beta');
expect(options[2]).toHaveTextContent('alpha');
});
test('preserves backend order even when sortAscending is false and sortMetric is specified', () => {
const testData = [
{ gender: 'zebra' },
{ gender: 'alpha' },
{ gender: 'beta' },
];
const testProps = {
...selectMultipleProps,
formData: {
...selectMultipleProps.formData,
sortMetric: 'count',
sortAscending: false,
},
queriesData: [
{
rowcount: 3,
colnames: ['gender'],
coltypes: [1],
data: testData,
applied_filters: [{ column: 'gender' }],
rejected_filters: [],
},
],
filterState: {
value: [],
label: '',
excludeFilterValues: true,
},
};
render(
// @ts-ignore
<SelectFilterPlugin
// @ts-ignore
{...transformProps(testProps)}
setDataMask={jest.fn()}
showOverflow={false}
/>,
{
useRedux: true,
initialState: {
nativeFilters: {
filters: {
'test-filter': {
name: 'Test Filter',
},
},
},
dataMask: {
'test-filter': {
extraFormData: {},
filterState: {
value: [],
label: '',
excludeFilterValues: true,
},
},
},
},
},
);
const filterSelect = screen.getAllByRole('combobox')[0];
userEvent.click(filterSelect);
// When sortMetric is specified, original order should be preserved regardless
// of sortAscending value (zebra, alpha, beta)
const options = screen.getAllByRole('option');
expect(options[0]).toHaveTextContent('zebra');
expect(options[1]).toHaveTextContent('alpha');
expect(options[2]).toHaveTextContent('beta');
});
});
@@ -317,13 +317,20 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
const sortComparator = useCallback(
(a: LabeledValue, b: LabeledValue) => {
// When sortMetric is specified, the backend already sorted the data correctly
// Don't override the backend's metric-based sorting with frontend alphabetical sorting
if (formData.sortMetric) {
return 0; // Preserve the original order from the backend
}
// Only apply alphabetical sorting when no sortMetric is specified
const labelComparator = propertyComparator('label');
if (formData.sortAscending) {
return labelComparator(a, b);
}
return labelComparator(b, a);
},
[formData.sortAscending],
[formData.sortAscending, formData.sortMetric],
);
// Use effect for initialisation for filter plugin
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,190 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
supersetGetCache,
clearDatasetCache,
clearAllDatasetCache,
} from './cachedSupersetGet';
describe('cachedSupersetGet', () => {
beforeEach(() => {
supersetGetCache.clear();
});
describe('clearDatasetCache', () => {
test('clears cache entries for specific dataset ID', () => {
supersetGetCache.set('/api/v1/dataset/123', { data: 'dataset123' });
supersetGetCache.set('/api/v1/dataset/123/', { data: 'dataset123slash' });
supersetGetCache.set('/api/v1/dataset/123?query=1', {
data: 'dataset123query',
});
supersetGetCache.set('/api/v1/dataset/456', { data: 'dataset456' });
supersetGetCache.set('/api/v1/other/123', { data: 'other' });
clearDatasetCache(123);
expect(supersetGetCache.has('/api/v1/dataset/123')).toBe(false);
expect(supersetGetCache.has('/api/v1/dataset/123/')).toBe(false);
expect(supersetGetCache.has('/api/v1/dataset/123?query=1')).toBe(false);
expect(supersetGetCache.has('/api/v1/dataset/456')).toBe(true);
expect(supersetGetCache.has('/api/v1/other/123')).toBe(true);
});
test('clears cache entries for string dataset ID', () => {
supersetGetCache.set('/api/v1/dataset/abc-123', { data: 'datasetAbc' });
supersetGetCache.set('/api/v1/dataset/abc-123/', {
data: 'datasetAbcSlash',
});
supersetGetCache.set('/api/v1/dataset/def-456', { data: 'datasetDef' });
clearDatasetCache('abc-123');
expect(supersetGetCache.has('/api/v1/dataset/abc-123')).toBe(false);
expect(supersetGetCache.has('/api/v1/dataset/abc-123/')).toBe(false);
expect(supersetGetCache.has('/api/v1/dataset/def-456')).toBe(true);
});
test('handles null dataset ID gracefully', () => {
supersetGetCache.set('/api/v1/dataset/123', { data: 'dataset123' });
clearDatasetCache(null as any);
expect(supersetGetCache.has('/api/v1/dataset/123')).toBe(true);
});
test('handles undefined dataset ID gracefully', () => {
supersetGetCache.set('/api/v1/dataset/123', { data: 'dataset123' });
clearDatasetCache(undefined as any);
expect(supersetGetCache.has('/api/v1/dataset/123')).toBe(true);
});
test('handles empty string dataset ID gracefully', () => {
supersetGetCache.set('/api/v1/dataset/123', { data: 'dataset123' });
clearDatasetCache('');
expect(supersetGetCache.has('/api/v1/dataset/123')).toBe(true);
});
test('does not clear unrelated cache entries', () => {
supersetGetCache.set('/api/v1/chart/123', { data: 'chart123' });
supersetGetCache.set('/api/v1/dashboard/123', { data: 'dashboard123' });
supersetGetCache.set('/api/v1/database/123', { data: 'database123' });
supersetGetCache.set('/api/v1/dataset/123', { data: 'dataset123' });
clearDatasetCache(123);
expect(supersetGetCache.has('/api/v1/chart/123')).toBe(true);
expect(supersetGetCache.has('/api/v1/dashboard/123')).toBe(true);
expect(supersetGetCache.has('/api/v1/database/123')).toBe(true);
expect(supersetGetCache.has('/api/v1/dataset/123')).toBe(false);
});
test('only clears exact dataset ID matches', () => {
supersetGetCache.set('/api/v1/dataset/1', { data: 'dataset1' });
supersetGetCache.set('/api/v1/dataset/12', { data: 'dataset12' });
supersetGetCache.set('/api/v1/dataset/123', { data: 'dataset123' });
supersetGetCache.set('/api/v1/dataset/1234', { data: 'dataset1234' });
supersetGetCache.set('/api/v1/dataset/456', { data: 'dataset456' });
clearDatasetCache(123);
expect(supersetGetCache.has('/api/v1/dataset/1')).toBe(true);
expect(supersetGetCache.has('/api/v1/dataset/12')).toBe(true);
expect(supersetGetCache.has('/api/v1/dataset/123')).toBe(false);
expect(supersetGetCache.has('/api/v1/dataset/1234')).toBe(true); // Should not be cleared - different ID
expect(supersetGetCache.has('/api/v1/dataset/456')).toBe(true);
});
test('clears cache entries with various URL patterns', () => {
supersetGetCache.set('/api/v1/dataset/789', { data: 'base' });
supersetGetCache.set('/api/v1/dataset/789/columns', { data: 'columns' });
supersetGetCache.set('/api/v1/dataset/789/related', { data: 'related' });
supersetGetCache.set('/api/v1/dataset/789?full=true', {
data: 'withQuery',
});
clearDatasetCache(789);
expect(supersetGetCache.has('/api/v1/dataset/789')).toBe(false);
expect(supersetGetCache.has('/api/v1/dataset/789/columns')).toBe(false);
expect(supersetGetCache.has('/api/v1/dataset/789/related')).toBe(false);
expect(supersetGetCache.has('/api/v1/dataset/789?full=true')).toBe(false);
});
});
describe('clearAllDatasetCache', () => {
test('clears all dataset cache entries', () => {
supersetGetCache.set('/api/v1/dataset/123', { data: 'dataset123' });
supersetGetCache.set('/api/v1/dataset/456', { data: 'dataset456' });
supersetGetCache.set('/api/v1/dataset/789/columns', { data: 'columns' });
supersetGetCache.set('/api/v1/chart/123', { data: 'chart123' });
supersetGetCache.set('/api/v1/dashboard/456', { data: 'dashboard456' });
clearAllDatasetCache();
expect(supersetGetCache.has('/api/v1/dataset/123')).toBe(false);
expect(supersetGetCache.has('/api/v1/dataset/456')).toBe(false);
expect(supersetGetCache.has('/api/v1/dataset/789/columns')).toBe(false);
expect(supersetGetCache.has('/api/v1/chart/123')).toBe(true);
expect(supersetGetCache.has('/api/v1/dashboard/456')).toBe(true);
});
test('handles empty cache gracefully', () => {
expect(supersetGetCache.size).toBe(0);
clearAllDatasetCache();
expect(supersetGetCache.size).toBe(0);
});
test('preserves non-dataset cache entries', () => {
supersetGetCache.set('/api/v1/chart/list', { data: 'chartList' });
supersetGetCache.set('/api/v1/dashboard/list', { data: 'dashboardList' });
supersetGetCache.set('/api/v1/database/list', { data: 'databaseList' });
supersetGetCache.set('/api/v1/query/list', { data: 'queryList' });
clearAllDatasetCache();
expect(supersetGetCache.has('/api/v1/chart/list')).toBe(true);
expect(supersetGetCache.has('/api/v1/dashboard/list')).toBe(true);
expect(supersetGetCache.has('/api/v1/database/list')).toBe(true);
expect(supersetGetCache.has('/api/v1/query/list')).toBe(true);
});
test('clears all variations of dataset endpoints', () => {
supersetGetCache.set('/api/v1/dataset/', { data: 'list' });
supersetGetCache.set('/api/v1/dataset/export', { data: 'export' });
supersetGetCache.set('/api/v1/dataset/import', { data: 'import' });
supersetGetCache.set('/api/v1/dataset/duplicate', { data: 'duplicate' });
supersetGetCache.set('/api/v1/dataset/1/refresh', { data: 'refresh' });
clearAllDatasetCache();
expect(supersetGetCache.has('/api/v1/dataset/')).toBe(false);
expect(supersetGetCache.has('/api/v1/dataset/export')).toBe(false);
expect(supersetGetCache.has('/api/v1/dataset/import')).toBe(false);
expect(supersetGetCache.has('/api/v1/dataset/duplicate')).toBe(false);
expect(supersetGetCache.has('/api/v1/dataset/1/refresh')).toBe(false);
});
});
});
@@ -27,3 +27,59 @@ export const cachedSupersetGet = cacheWrapper(
supersetGetCache,
({ endpoint }) => endpoint || '',
);
/**
* Clear cached responses for dataset-related endpoints
* @param datasetId - The ID of the dataset to clear from cache
*/
export function clearDatasetCache(datasetId: number | string): void {
if (datasetId === null || datasetId === undefined || datasetId === '') return;
const datasetIdStr = String(datasetId);
supersetGetCache.forEach((_value, key) => {
// Match exact dataset ID patterns:
// - /api/v1/dataset/123 (exact match or end of URL)
// - /api/v1/dataset/123/ (with trailing slash)
// - /api/v1/dataset/123? (with query params)
const patterns = [
`/api/v1/dataset/${datasetIdStr}`,
`/api/v1/dataset/${datasetIdStr}/`,
`/api/v1/dataset/${datasetIdStr}?`,
];
for (const pattern of patterns) {
if (key.includes(pattern)) {
// Additional check to ensure we don't match longer IDs
const afterPattern = key.substring(
key.indexOf(pattern) + pattern.length,
);
// If pattern ends with slash or query, it's already precise
if (pattern.endsWith('/') || pattern.endsWith('?')) {
supersetGetCache.delete(key);
break;
}
// For the base pattern, ensure nothing follows or only valid separators
if (
afterPattern === '' ||
afterPattern.startsWith('/') ||
afterPattern.startsWith('?')
) {
supersetGetCache.delete(key);
break;
}
}
}
});
}
/**
* Clear all cached dataset responses
*/
export function clearAllDatasetCache(): void {
supersetGetCache.forEach((_value, key) => {
if (key.includes('/api/v1/dataset/')) {
supersetGetCache.delete(key);
}
});
}
+51 -1
View File
@@ -17,7 +17,12 @@
* under the License.
*/
import { isUrlExternal, parseUrl, toQueryString } from './urlUtils';
import {
isUrlExternal,
parseUrl,
toQueryString,
getDashboardUrlParams,
} from './urlUtils';
test('isUrlExternal', () => {
expect(isUrlExternal('http://google.com')).toBeTruthy();
@@ -95,3 +100,48 @@ test('toQueryString should handle special characters in keys and values', () =>
'?user%40domain=me%26you',
);
});
test('getDashboardUrlParams should exclude edit parameter by default', () => {
// Mock window.location.search to include edit parameter
const originalLocation = window.location;
Object.defineProperty(window, 'location', {
value: {
...originalLocation,
search: '?edit=true&standalone=false&expand_filters=1',
},
writable: true,
});
const urlParams = getDashboardUrlParams(['edit']);
const paramNames = urlParams.map(([key]) => key);
expect(paramNames).not.toContain('edit');
expect(paramNames).toContain('standalone');
expect(paramNames).toContain('expand_filters');
// Restore original location
window.location = originalLocation;
});
test('getDashboardUrlParams should exclude multiple parameters when provided', () => {
// Mock window.location.search with multiple parameters
const originalLocation = window.location;
Object.defineProperty(window, 'location', {
value: {
...originalLocation,
search: '?edit=true&standalone=false&debug=true&test=value',
},
writable: true,
});
const urlParams = getDashboardUrlParams(['edit', 'debug']);
const paramNames = urlParams.map(([key]) => key);
expect(paramNames).not.toContain('edit');
expect(paramNames).not.toContain('debug');
expect(paramNames).toContain('standalone');
expect(paramNames).toContain('test');
// Restore original location
window.location = originalLocation;
});
+6
View File
@@ -35,6 +35,7 @@ const {
getCompilerHooks,
} = require('webpack-manifest-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const parsedArgs = require('yargs').argv;
const Visualizer = require('webpack-visualizer-plugin2');
const getProxyConfig = require('./webpack.proxy-config');
@@ -181,6 +182,11 @@ if (!process.env.CI) {
plugins.push(new webpack.ProgressPlugin());
}
// Add React Refresh plugin for development mode
if (isDevMode) {
plugins.push(new ReactRefreshWebpackPlugin());
}
if (!isDevMode) {
// text loading (webpack 4+)
plugins.push(
+30 -9
View File
@@ -171,7 +171,7 @@ class ChartDataRestApi(ChartRestApi):
and query_context.result_format == ChartDataResultFormat.JSON
and query_context.result_type == ChartDataResultType.FULL
):
return self._run_async(json_body, command)
return self._run_async(json_body, command, add_extra_log_payload)
try:
form_data = json.loads(chart.params)
@@ -265,7 +265,7 @@ class ChartDataRestApi(ChartRestApi):
and query_context.result_format == ChartDataResultFormat.JSON
and query_context.result_type == ChartDataResultType.FULL
):
return self._run_async(json_body, command)
return self._run_async(json_body, command, add_extra_log_payload)
form_data = json_body.get("form_data")
return self._get_data_response(
@@ -334,7 +334,10 @@ class ChartDataRestApi(ChartRestApi):
return self._get_data_response(command, True)
def _run_async(
self, form_data: dict[str, Any], command: ChartDataCommand
self,
form_data: dict[str, Any],
command: ChartDataCommand,
add_extra_log_payload: Callable[..., None] | None = None,
) -> Response:
"""
Execute command as an async query.
@@ -343,6 +346,10 @@ class ChartDataRestApi(ChartRestApi):
with contextlib.suppress(ChartDataCacheLoadError):
result = command.run(force_cached=True)
if result is not None:
# Log is_cached if extra payload callback is provided.
# This indicates no async job was triggered - data was already cached
# and a synchronous response is being returned immediately.
self._log_is_cached(result, add_extra_log_payload)
return self._send_chart_response(result)
# Otherwise, kick off a background job to run the chart query.
# Clients will either poll or be notified of query completion,
@@ -424,6 +431,25 @@ class ChartDataRestApi(ChartRestApi):
return self.response_400(message=f"Unsupported result_format: {result_format}")
def _log_is_cached(
self,
result: dict[str, Any],
add_extra_log_payload: Callable[..., None] | None,
) -> None:
"""
Log is_cached values from query results to event logger.
Extracts is_cached from each query in the result and logs it.
If there's a single query, logs the boolean value directly.
If multiple queries, logs as a list.
"""
if add_extra_log_payload and result and "queries" in result:
is_cached_values = [query.get("is_cached") for query in result["queries"]]
if len(is_cached_values) == 1:
add_extra_log_payload(is_cached=is_cached_values[0])
elif is_cached_values:
add_extra_log_payload(is_cached=is_cached_values)
@event_logger.log_this
def _get_data_response(
self,
@@ -442,12 +468,7 @@ class ChartDataRestApi(ChartRestApi):
return self.response_400(message=exc.message)
# Log is_cached if extra payload callback is provided
if add_extra_log_payload and result and "queries" in result:
is_cached_values = [query.get("is_cached") for query in result["queries"]]
if len(is_cached_values) == 1:
add_extra_log_payload(is_cached=is_cached_values[0])
elif is_cached_values:
add_extra_log_payload(is_cached=is_cached_values)
self._log_is_cached(result, add_extra_log_payload)
return self._send_chart_response(result, form_data, datasource)
+6 -3
View File
@@ -1482,9 +1482,12 @@ class ChartDataResponseResult(Schema):
allow_none=None,
)
query = fields.String(
metadata={"description": "The executed query statement"},
required=True,
allow_none=False,
metadata={
"description": "The executed query statement. May be absent when "
"validation errors occur."
},
required=False,
allow_none=True,
)
status = fields.String(
metadata={"description": "Status of the query"},
@@ -24,6 +24,7 @@ from superset.commands.chart.exceptions import (
ChartDataCacheLoadError,
ChartDataQueryFailedError,
)
from superset.common.chart_data import ChartDataResultType
from superset.common.query_context import QueryContext
from superset.exceptions import CacheLoadError
@@ -48,8 +49,13 @@ class ChartDataCommand(BaseCommand):
except CacheLoadError as ex:
raise ChartDataCacheLoadError(ex.message) from ex
# Skip error check for query-only requests - errors are returned in payload
# This allows View Query modal to display validation errors
for query in payload["queries"]:
if query.get("error"):
if (
query.get("error")
and self._query_context.result_type != ChartDataResultType.QUERY
):
raise ChartDataQueryFailedError(
_("Error: %(error)s", error=query["error"])
)
+78 -23
View File
@@ -177,19 +177,35 @@ class BaseReportState:
"""
Creates a Report execution log, uses the current computed last_value for Alerts
"""
log = ReportExecutionLog(
scheduled_dttm=self._scheduled_dttm,
start_dttm=self._start_dttm,
end_dttm=datetime.utcnow(),
value=self._report_schedule.last_value,
value_row_json=self._report_schedule.last_value_row_json,
state=self._report_schedule.last_state,
error_message=error_message,
report_schedule=self._report_schedule,
uuid=self._execution_id,
)
db.session.add(log)
db.session.commit() # pylint: disable=consider-using-transaction
from sqlalchemy.orm.exc import StaleDataError
try:
log = ReportExecutionLog(
scheduled_dttm=self._scheduled_dttm,
start_dttm=self._start_dttm,
end_dttm=datetime.utcnow(),
value=self._report_schedule.last_value,
value_row_json=self._report_schedule.last_value_row_json,
state=self._report_schedule.last_state,
error_message=error_message,
report_schedule=self._report_schedule,
uuid=self._execution_id,
)
db.session.add(log)
db.session.commit() # pylint: disable=consider-using-transaction
except StaleDataError as ex:
# Report schedule was modified or deleted by another process
db.session.rollback()
logger.warning(
"Report schedule (execution %s) was modified or deleted "
"during execution. This can occur when a report is deleted "
"while running.",
self._execution_id,
)
raise ReportScheduleUnexpectedError(
"Report schedule was modified or deleted by another process "
"during execution"
) from ex
def _get_url(
self,
@@ -743,7 +759,7 @@ class ReportNotTriggeredErrorState(BaseReportState):
current_states = [ReportState.NOOP, ReportState.ERROR]
initial = True
def next(self) -> None:
def next(self) -> None: # noqa: C901
self.update_report_schedule_and_log(ReportState.WORKING)
try:
# If it's an alert check if the alert is triggered
@@ -758,9 +774,21 @@ class ReportNotTriggeredErrorState(BaseReportState):
if isinstance(first_ex, SupersetErrorsException):
error_message = ";".join([error.message for error in first_ex.errors])
self.update_report_schedule_and_log(
ReportState.ERROR, error_message=error_message
)
try:
self.update_report_schedule_and_log(
ReportState.ERROR, error_message=error_message
)
except ReportScheduleUnexpectedError as logging_ex:
# Logging failed (likely StaleDataError), but we still want to
# raise the original error so the root cause remains visible
logger.warning(
"Failed to log error for report schedule (execution %s) "
"due to database issue",
self._execution_id,
exc_info=True,
)
# Re-raise the original exception, not the logging failure
raise first_ex from logging_ex
# TODO (dpgaspar) convert this logic to a new state eg: ERROR_ON_GRACE
if not self.is_in_error_grace_period():
@@ -776,12 +804,26 @@ class ReportNotTriggeredErrorState(BaseReportState):
second_error_message = ";".join(
[error.message for error in second_ex.errors]
)
except ReportScheduleUnexpectedError:
# send_error failed due to logging issue, log and continue
# to raise the original error
logger.warning(
"Failed to send error notification due to database issue",
exc_info=True,
)
except Exception as second_ex: # pylint: disable=broad-except
second_error_message = str(second_ex)
finally:
self.update_report_schedule_and_log(
ReportState.ERROR, error_message=second_error_message
)
try:
self.update_report_schedule_and_log(
ReportState.ERROR, error_message=second_error_message
)
except ReportScheduleUnexpectedError:
# Logging failed again, log it but don't let it hide first_ex
logger.warning(
"Failed to log final error state due to database issue",
exc_info=True,
)
raise
@@ -851,9 +893,22 @@ class ReportSuccessState(BaseReportState):
self.send()
self.update_report_schedule_and_log(ReportState.SUCCESS)
except Exception as ex: # pylint: disable=broad-except
self.update_report_schedule_and_log(
ReportState.ERROR, error_message=str(ex)
)
try:
self.update_report_schedule_and_log(
ReportState.ERROR, error_message=str(ex)
)
except ReportScheduleUnexpectedError as logging_ex:
# Logging failed (likely StaleDataError), but we still want to
# raise the original error so the root cause remains visible
logger.warning(
"Failed to log error for report schedule (execution %s) "
"due to database issue",
self._execution_id,
exc_info=True,
)
# Re-raise the original exception, not the logging failure
raise ex from logging_ex
raise
class ReportScheduleStateMachine: # pylint: disable=too-few-public-methods
+9 -1
View File
@@ -24,7 +24,7 @@ from flask_babel import _
from superset.common.chart_data import ChartDataResultType
from superset.common.db_query_status import QueryStatus
from superset.connectors.sqla.models import BaseDatasource
from superset.exceptions import QueryObjectValidationError
from superset.exceptions import QueryObjectValidationError, SupersetParseError
from superset.utils.core import (
extract_column_dtype,
extract_dataframe_dtypes,
@@ -86,7 +86,15 @@ def _get_query(
try:
result["query"] = datasource.get_query_str(query_obj.to_dict())
except QueryObjectValidationError as err:
# Validation errors (missing required fields, invalid config)
# No SQL was generated
result["error"] = err.message
except SupersetParseError as err:
# Parsing errors (SQL optimization/parsing failed)
# SQL was generated but couldn't be optimized - show both
if err.error.extra and (sql := err.error.extra.get("sql")) is not None:
result["query"] = sql
result["error"] = err.error.message
return result
+7 -1
View File
@@ -1502,8 +1502,14 @@ class SqlaTable(
"""
label = utils.get_column_name(col)
try:
sql_expression = col["sqlExpression"]
# For column references, conditionally quote identifiers that need it
if col.get("isColumnReference"):
sql_expression = self.database.quote_identifier(sql_expression)
expression = self._process_select_expression(
expression=col["sqlExpression"],
expression=sql_expression,
database_id=self.database_id,
engine=self.database.backend,
schema=self.schema,
+4 -3
View File
@@ -67,7 +67,8 @@ Explore & Analysis:
- generate_explore_link: Create pre-configured explore URL with dataset/metrics/filters
System Information:
- get_superset_instance_info: Get instance-wide statistics and metadata
- get_instance_info: Get instance-wide statistics and metadata
- health_check: Simple health check tool (takes NO parameters, call without arguments)
Available Resources:
- superset://instance/metadata: Access instance configuration and metadata
@@ -118,7 +119,7 @@ General usage tips:
- All tools return structured, Pydantic-typed responses
- Chart previews are served as PNG images via custom screenshot endpoints
If you are unsure which tool to use, start with get_superset_instance_info
If you are unsure which tool to use, start with get_instance_info
or use the superset_quickstart prompt for an interactive guide.
"""
@@ -283,7 +284,7 @@ from superset.mcp_service.system import ( # noqa: F401, E402
resources as system_resources,
)
from superset.mcp_service.system.tool import ( # noqa: F401, E402
get_superset_instance_info,
get_instance_info,
health_check,
)
+18
View File
@@ -0,0 +1,18 @@
# 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.
"""System-level MCP service tools and utilities."""
@@ -82,7 +82,7 @@ I'll help you through these steps:
5. 🚀 **Learn Advanced Features** - Discover filters, SQL Lab, and more
To get started, I'll use these tools:
- `get_superset_instance_info` - Overview of your Superset instance
- `get_instance_info` - Overview of your Superset instance
- `list_datasets` - Find available datasets
- `get_dataset_info` - Explore dataset details
- `generate_chart` - Create visualizations
@@ -55,7 +55,7 @@ def get_instance_metadata_resource() -> str:
from superset.daos.user import UserDAO
from superset.mcp_service.mcp_core import InstanceInfoCore
from superset.mcp_service.system.schemas import InstanceInfo
from superset.mcp_service.system.tool.get_superset_instance_info import (
from superset.mcp_service.system.system_utils import (
calculate_dashboard_breakdown,
calculate_database_breakdown,
calculate_instance_summary,
@@ -101,7 +101,7 @@ def get_instance_metadata_resource() -> str:
"error": "Unable to fetch complete metadata",
"tips": [
"Use list_datasets to explore available data",
"Use get_superset_instance_info for basic stats",
"Use get_instance_info for basic stats",
],
}
)
+1 -2
View File
@@ -45,14 +45,13 @@ class HealthCheckResponse(BaseModel):
class GetSupersetInstanceInfoRequest(BaseModel):
"""
Request schema for get_superset_instance_info tool.
Request schema for get_instance_info tool.
Currently has no parameters but provides consistent API for future extensibility.
"""
model_config = ConfigDict(
extra="forbid",
str_strip_whitespace=True,
)
@@ -16,30 +16,22 @@
# under the License.
"""
Get Superset instance high-level information FastMCP tool using configurable
InstanceInfoCore for flexible, extensible metrics calculation.
System-level utility functions for MCP service.
This module contains helper functions used by system tools for calculating
instance metrics, dashboard breakdowns, database breakdowns, and activity summaries.
"""
import logging
from typing import Any, Dict
from fastmcp import Context
from superset.mcp_service.app import mcp
from superset.mcp_service.auth import mcp_auth_hook
from superset.mcp_service.mcp_core import InstanceInfoCore
from superset.mcp_service.system.schemas import (
DashboardBreakdown,
DatabaseBreakdown,
GetSupersetInstanceInfoRequest,
InstanceInfo,
InstanceSummary,
PopularContent,
RecentActivity,
)
logger = logging.getLogger(__name__)
def calculate_dashboard_breakdown(
base_counts: Dict[str, int],
@@ -202,67 +194,3 @@ def calculate_popular_content(
top_tags=[],
top_creators=[],
)
# Configure the instance info core
_instance_info_core = InstanceInfoCore(
dao_classes={
"dashboards": None, # type: ignore[dict-item] # Will be set at runtime
"charts": None, # type: ignore[dict-item]
"datasets": None, # type: ignore[dict-item]
"databases": None, # type: ignore[dict-item]
"users": None, # type: ignore[dict-item]
"tags": None, # type: ignore[dict-item]
},
output_schema=InstanceInfo,
metric_calculators={
"instance_summary": calculate_instance_summary,
"recent_activity": calculate_recent_activity,
"dashboard_breakdown": calculate_dashboard_breakdown,
"database_breakdown": calculate_database_breakdown,
"popular_content": calculate_popular_content,
},
time_windows={
"recent": 7,
"monthly": 30,
"quarterly": 90,
},
logger=logger,
)
@mcp.tool
@mcp_auth_hook
def get_superset_instance_info(
request: GetSupersetInstanceInfoRequest, ctx: Context
) -> InstanceInfo:
"""Get Superset instance statistics.
Returns counts, activity metrics, and database types.
"""
try:
# Import DAOs at runtime to avoid circular imports
from superset.daos.chart import ChartDAO
from superset.daos.dashboard import DashboardDAO
from superset.daos.database import DatabaseDAO
from superset.daos.dataset import DatasetDAO
from superset.daos.tag import TagDAO
from superset.daos.user import UserDAO
# Configure DAO classes at runtime
_instance_info_core.dao_classes = {
"dashboards": DashboardDAO,
"charts": ChartDAO,
"datasets": DatasetDAO,
"databases": DatabaseDAO,
"users": UserDAO,
"tags": TagDAO,
}
# Run the configurable core
return _instance_info_core.run_tool()
except Exception as e:
error_msg = f"Unexpected error in instance info: {str(e)}"
logger.error(error_msg, exc_info=True)
raise
+2 -2
View File
@@ -17,10 +17,10 @@
"""System tools for MCP service."""
from .get_superset_instance_info import get_superset_instance_info
from .get_instance_info import get_instance_info
from .health_check import health_check
__all__ = [
"health_check",
"get_superset_instance_info",
"get_instance_info",
]
@@ -0,0 +1,106 @@
# 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.
"""
Get Superset instance high-level information FastMCP tool using configurable
InstanceInfoCore for flexible, extensible metrics calculation.
"""
import logging
from fastmcp import Context
from superset.mcp_service.app import mcp
from superset.mcp_service.auth import mcp_auth_hook
from superset.mcp_service.mcp_core import InstanceInfoCore
from superset.mcp_service.system.schemas import (
GetSupersetInstanceInfoRequest,
InstanceInfo,
)
from superset.mcp_service.system.system_utils import (
calculate_dashboard_breakdown,
calculate_database_breakdown,
calculate_instance_summary,
calculate_popular_content,
calculate_recent_activity,
)
logger = logging.getLogger(__name__)
# Configure the instance info core
_instance_info_core = InstanceInfoCore(
dao_classes={
"dashboards": None, # type: ignore[dict-item] # Will be set at runtime
"charts": None, # type: ignore[dict-item]
"datasets": None, # type: ignore[dict-item]
"databases": None, # type: ignore[dict-item]
"users": None, # type: ignore[dict-item]
"tags": None, # type: ignore[dict-item]
},
output_schema=InstanceInfo,
metric_calculators={
"instance_summary": calculate_instance_summary,
"recent_activity": calculate_recent_activity,
"dashboard_breakdown": calculate_dashboard_breakdown,
"database_breakdown": calculate_database_breakdown,
"popular_content": calculate_popular_content,
},
time_windows={
"recent": 7,
"monthly": 30,
"quarterly": 90,
},
logger=logger,
)
@mcp.tool
@mcp_auth_hook
def get_instance_info(
request: GetSupersetInstanceInfoRequest, ctx: Context
) -> InstanceInfo:
"""Get Superset instance statistics.
Returns counts, activity metrics, and database types.
"""
try:
# Import DAOs at runtime to avoid circular imports
from superset.daos.chart import ChartDAO
from superset.daos.dashboard import DashboardDAO
from superset.daos.database import DatabaseDAO
from superset.daos.dataset import DatasetDAO
from superset.daos.tag import TagDAO
from superset.daos.user import UserDAO
# Configure DAO classes at runtime
_instance_info_core.dao_classes = {
"dashboards": DashboardDAO,
"charts": ChartDAO,
"datasets": DatasetDAO,
"databases": DatabaseDAO,
"users": UserDAO,
"tags": TagDAO,
}
# Run the configurable core
return _instance_info_core.run_tool()
except Exception as e:
error_msg = f"Unexpected error in instance info: {str(e)}"
logger.error(error_msg, exc_info=True)
raise
@@ -21,9 +21,12 @@ import datetime
import logging
import platform
from flask import current_app
from superset.mcp_service.app import mcp
from superset.mcp_service.auth import mcp_auth_hook
from superset.mcp_service.system.schemas import HealthCheckResponse
from superset.utils.version import get_version_metadata
logger = logging.getLogger(__name__)
@@ -34,18 +37,44 @@ async def health_check() -> HealthCheckResponse:
"""
Simple health check tool for testing the MCP service.
IMPORTANT: This tool takes NO parameters. Call it without any arguments.
Returns basic system information and confirms the service is running.
This is useful for testing connectivity and basic functionality.
Parameters:
None - This tool does not accept any parameters
Returns:
HealthCheckResponse: Health status and system information
HealthCheckResponse: Health status and system information including:
- status: "healthy" or "error"
- timestamp: ISO format timestamp
- service: Service name from APP_NAME config (e.g., "Superset MCP Service")
- version: Superset version string
- python_version: Python version
- platform: Operating system platform
Example:
# Correct - no parameters
health_check()
# Incorrect - do not pass any arguments
# health_check(request={}) # This will cause validation errors
"""
# Get app name from config (safe to do outside try block)
app_name = current_app.config.get("APP_NAME", "Superset")
service_name = f"{app_name} MCP Service"
try:
# Get version from Superset version metadata
version_metadata = get_version_metadata()
version = version_metadata.get("version_string", "unknown")
response = HealthCheckResponse(
status="healthy",
timestamp=datetime.datetime.now().isoformat(),
service="Superset MCP Service",
version="1.0.0",
service=service_name,
version=version,
python_version=platform.python_version(),
platform=platform.system(),
)
@@ -56,11 +85,12 @@ async def health_check() -> HealthCheckResponse:
except Exception as e:
logger.error("Health check failed: %s", e)
# Return error status but don't raise to keep tool working
return HealthCheckResponse(
response = HealthCheckResponse(
status="error",
timestamp=datetime.datetime.now().isoformat(),
service="Superset MCP Service",
version="1.0.0",
service=service_name,
version="unknown",
python_version=platform.python_version(),
platform=platform.system(),
)
return response
+28 -9
View File
@@ -337,16 +337,32 @@ class SqlLabRestApi(BaseSupersetApi):
params = kwargs["rison"]
key = params.get("key")
rows = params.get("rows")
result = SqlExecutionResultsCommand(key=key, rows=rows).run()
try:
result = SqlExecutionResultsCommand(key=key, rows=rows).run()
except Exception as ex:
logger.exception("Error fetching query results for key=%s", key)
return self.response_500(message=str(ex))
# Using pessimistic json serialization since some database drivers can return
# unserializeable types at times
payload = json.dumps(
result,
default=json.pessimistic_json_iso_dttm_ser,
ignore_nan=True,
)
return json_success(payload, 200)
try:
payload = json.dumps(
result,
default=json.pessimistic_json_iso_dttm_ser,
ignore_nan=True,
)
except Exception as ex:
logger.exception("Error serializing query results for key=%s", key)
return self.response_500(message="Unable to serialize query results")
# Use json_success with explicit Content-Type to ensure Flask 2.3+ correctly
# handles the response and doesn't trigger HTTP 406 errors due to content
# negotiation issues with Accept headers or proxy configurations
response = json_success(payload, 200)
# Explicitly set Content-Type as a safeguard against content negotiation issues
response.headers["Content-Type"] = "application/json; charset=utf-8"
return response
@expose("/execute/", methods=("POST",))
@protect()
@@ -410,8 +426,11 @@ class SqlLabRestApi(BaseSupersetApi):
if command_result["status"] == SqlJsonExecutionStatus.QUERY_IS_RUNNING
else 200
)
# return the execution result without special encoding
return json_success(command_result["payload"], response_status)
# Return the execution result without special encoding
# Set explicit Content-Type to prevent Flask 2.3+ content negotiation issues
response = json_success(command_result["payload"], response_status)
response.headers["Content-Type"] = "application/json; charset=utf-8"
return response
except SqlLabException as ex:
payload = {"errors": [ex.to_dict()]}
+1
View File
@@ -59,6 +59,7 @@ class AdhocColumn(TypedDict, total=False):
hasCustomLabel: Optional[bool]
label: str
sqlExpression: str
isColumnReference: Optional[bool]
columnType: Optional[Literal["BASE_AXIS", "SERIES"]]
timeGrain: Optional[str]
+137 -67
View File
@@ -20,8 +20,8 @@ msgstr ""
"POT-Creation-Date: 2025-04-29 12:34+0330\n"
"PO-Revision-Date: 2016-05-02 08:49-0700\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en\n"
"Language-Team: en <LL@li.org>\n"
"Language: es\n"
"Language-Team: Español; Castellano <>\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -91,6 +91,10 @@ msgstr " uno nuevo"
msgid " at line %(line)d"
msgstr " en la línea %(line)d"
#, python-format
msgid " at line %(line)d"
msgstr ""
msgid " expression which needs to adhere to the "
msgstr " expresión que debe adherirse al "
@@ -184,7 +188,14 @@ msgstr "la frecuencia de %(report_type)s programación excede el límite. Config
#, python-format
msgid "%(rows)d rows returned"
msgstr "%(rows)d filas devueltas"
msgstr "líneas obtenidas"
#, python-format
msgid ""
"%(subtitle)s\n"
"This may be triggered by:\n"
" %(issue)s"
msgstr ""
#, fuzzy, python-format
msgid "%(suggestion)s instead of \"%(undefinedParameter)s?\""
@@ -255,7 +266,7 @@ msgstr "%s elementos no se han podido etiquetar porque no tienes permisos de edi
msgid "%s operator(s)"
msgstr "%s operador(es)"
#, fuzzy, python-format
#, python-format
msgid "%s option"
msgid_plural "%s options"
msgstr[0] "%s opción"
@@ -269,7 +280,7 @@ msgstr "%s opción(es)"
msgid "%s recipients"
msgstr "%s destinatarios"
#, fuzzy, python-format
#, python-format
msgid "%s row"
msgid_plural "%s rows"
msgstr[0] "%s fila"
@@ -1065,14 +1076,14 @@ msgstr "La consulta de alerta ha devuelto más de una columna."
#, python-format
msgid "Alert query returned more than one column. %(num_cols)s columns returned"
msgstr "La consulta de alerta ha devuelto más de una columna. %(num_cols)s columnas devueltas"
msgstr "La consulta de alerta devolvió más de una columna. %(num_cols)s columnas devueltas"
msgid "Alert query returned more than one row."
msgstr "La consulta de alerta ha devuelto más de una fila."
#, python-format
msgid "Alert query returned more than one row. %(num_rows)s rows returned"
msgstr "La consulta de alerta ha devuelto más de una fila. %(num_rows)s filas devueltas"
msgstr "La consulta de alerta devolvió más de una fila. %(num_rows)s filas devueltas"
msgid "Alert running"
msgstr "Alerta en ejecución"
@@ -1242,7 +1253,7 @@ msgid "An error occurred while creating %ss: %s"
msgstr "Se ha producido un error al crear %ss: %s"
msgid "An error occurred while creating the copy link."
msgstr "Se ha producido un error al crear el enlace de copia."
msgstr "Se produjo un error en la creación %ss: %s"
msgid "An error occurred while creating the data source"
msgstr "Se ha producido un error al crear la fuente de datos"
@@ -1339,7 +1350,7 @@ msgstr "Se ha producido un error al recuperar los valores del usuario: %s"
#, python-format
msgid "An error occurred while importing %s: %s"
msgstr "Se ha producido un error al importar %s: %s"
msgstr "Se produjo un error importando %s: %s"
msgid "An error occurred while loading dashboard information."
msgstr "Se ha producido un error al cargar la información del panel de control."
@@ -1535,7 +1546,7 @@ msgstr "Filtros aplicados (%s)"
#, python-format
msgid "Applied filters: %s"
msgstr "Filtros aplicados: %s"
msgstr "Filtros aplicados %s"
msgid ""
"Applied rolling window did not return any data. Please make sure the "
@@ -1577,7 +1588,7 @@ msgstr "¿Seguro que quieres eliminar?"
#, python-format
msgid "Are you sure you want to delete %s?"
msgstr "¿Seguro que quieres eliminar %s?"
msgstr "¿Está seguro de que desea eliminar %s?"
#, python-format
msgid "Are you sure you want to delete the selected %s?"
@@ -2238,7 +2249,7 @@ msgstr "Opciones del gráfico"
msgid "Chart Orientation"
msgstr "Orientación del gráfico"
#, fuzzy, python-format
#, python-format
msgid "Chart Owner: %s"
msgid_plural "Chart Owners: %s"
msgstr[0] "Propietario del gráfico: %s"
@@ -2252,15 +2263,15 @@ msgstr "Título del gráfico"
#, python-format
msgid "Chart [%s] has been overwritten"
msgstr "El gráfico [%s] se ha sobrescrito"
msgstr "El gráfico [%s] ha sido sobreescrito"
#, python-format
msgid "Chart [%s] has been saved"
msgstr "El gráfico [%s] se ha guardado"
msgstr "El gráfico [%s] ha sido guardado"
#, python-format
msgid "Chart [%s] was added to dashboard [%s]"
msgstr "El gráfico [%s] se ha añadido al panel de control [%s]"
msgstr "El gráfico [%s] ha sido añadido al panel de control [%s]"
msgid "Chart [{}] has been overwritten"
msgstr "El gráfico [{}] se ha sobrescrito"
@@ -2796,7 +2807,7 @@ msgid "Configuration"
msgstr "Configuración"
msgid "Configure Advanced Time Range "
msgstr "Configurar el intervalo de tiempo avanzado "
msgstr "Configuración avanzada de rango de tiempo "
msgid "Configure Time Range: Current..."
msgstr "Configurar el intervalo de tiempo: actual..."
@@ -2805,7 +2816,7 @@ msgid "Configure Time Range: Last..."
msgstr "Configurar el intervalo de tiempo: último..."
msgid "Configure Time Range: Previous..."
msgstr "Configurar el intervalo de tiempo: anterior..."
msgstr "Configurar Rango de Tiempo: Anteriores..."
msgid "Configure custom time range"
msgstr "Configurar intervalo de tiempo personalizado"
@@ -3814,7 +3825,7 @@ msgid_plural "Deleted %(num)d report schedules"
msgstr[0] "Se ha eliminado%(num)d programación de informe"
msgstr[1] "Se han eliminado%(num)d programaciones de informe"
#, fuzzy, python-format
#, python-format
msgid "Deleted %(num)d rules"
msgid_plural "Deleted %(num)d rules"
msgstr[0] "Se han eliminado%(num)d reglas"
@@ -4112,13 +4123,11 @@ msgstr "El desglose en detalle está deshabilitado para esta base de datos. Camb
msgid "Drill to detail: %s"
msgstr "Desglosar en detalle: %s"
#, fuzzy
msgid "Drop a column here or click"
msgid_plural "Drop columns here or click"
msgstr[0] "Suelta una columna aquí o haz clic"
msgstr[1] "Suelta las columnas aquí o haz clic"
#, fuzzy
msgid "Drop a column/metric here or click"
msgid_plural "Drop columns/metrics here or click"
msgstr[0] "Suelta una columna/métrica aquí o haz clic"
@@ -4185,10 +4194,10 @@ msgid "Duration in ms (1.40008 => 1ms 400µs 80ns)"
msgstr "Duración en ms (1,40008 => 1 ms 400 µs 80 ns)"
msgid "Duration in ms (100.40008 => 100ms 400µs 80ns)"
msgstr "Duración en ms (100,40008 => 100 ms 400 µs 80 ns)"
msgstr "Duración en ms (100.40008 => 100 ms 400 µs 80 ns)"
msgid "Duration in ms (10500 => 0:10.5)"
msgstr "Duración en ms (10 500 => 0:10,5)"
msgstr "Duración en ms (10500 => 0:10.5)"
msgid "Duration in ms (66000 => 1m 6s)"
msgstr "Duración en ms (66 000 => 1 m 6 s)"
@@ -4235,6 +4244,9 @@ msgstr "Editar CSS"
msgid "Edit CSS template properties"
msgstr "Editar propiedades de la plantilla CSS"
msgid "Edit Chart"
msgstr "Editar Gráfico"
msgid "Edit Chart Properties"
msgstr "Editar propiedades del gráfico"
@@ -4369,7 +4381,7 @@ msgid "Embed dashboard"
msgstr "Incrustar panel de control"
msgid "Embedded dashboard could not be deleted."
msgstr "No se ha podido eliminar el panel de control incrustado."
msgstr "El panel de control no pudo ser eliminado."
msgid "Embedding deactivated."
msgstr "Incrustación desactivada."
@@ -4453,7 +4465,7 @@ msgid "End date"
msgstr "Fecha final"
msgid "End date excluded from time range"
msgstr "Fecha final excluida del intervalo de tiempo"
msgstr "Fecha final excluida del rango de tiempo"
msgid "End date must be after start date"
msgstr "La fecha final debe ser posterior a la fecha inicial"
@@ -4572,9 +4584,6 @@ msgstr "Error al leer el archivo de Excel"
msgid "Error saving dataset"
msgstr "Error al guardar el conjunto de datos"
msgid "Error unfaving chart"
msgstr "Error al quitar el gráfico de favoritos"
msgid "Error while adding role!"
msgstr "Error al añadir el rol"
@@ -4584,6 +4593,9 @@ msgstr "Error al añadir el usuario"
msgid "Error while duplicating role!"
msgstr "Error al duplicar el rol"
msgid "Error unfaving chart"
msgstr "Error al quitar el gráfico de favoritos"
msgid "Error while fetching charts"
msgstr "Error al recuperar los gráficos"
@@ -5218,10 +5230,10 @@ msgid "Geometry Column"
msgstr "Columna de geometría"
msgid "Get the last date by the date unit."
msgstr "Obtener la última fecha por la unidad de fecha."
msgstr "Obtiene la última fecha para la unidad de fecha especificada."
msgid "Get the specify date for the holiday"
msgstr "Obtener la fecha especificada para el día festivo"
msgstr "Obtiene la fecha del día feriado especificado"
msgid "Give access to multiple catalogs in a single database connection."
msgstr "Da acceso a múltiples catálogos en una sola conexión de base de datos."
@@ -5496,7 +5508,7 @@ msgstr "Incluye una descripción que se enviará con tu informe"
#, python-format
msgid "Include description to be sent with %s"
msgstr "Incluye una descripción para enviarse con %s"
msgstr "Incluye una descripción para ser enviada con %s"
msgid "Include series name as an axis"
msgstr "Incluir el nombre de la serie como eje"
@@ -5584,7 +5596,7 @@ msgstr "JSON no válido"
#, python-format
msgid "Invalid advanced data type: %(advanced_data_type)s"
msgstr "Tipo de datos avanzados no válido: %(advanced_data_type)s"
msgstr "Tipo de información avanzada inválida: %(advanced_data_type)s"
msgid "Invalid certificate"
msgstr "Certificado no válido"
@@ -5657,7 +5669,7 @@ msgstr "Referencia no válida a la columna: «%(column)s»"
#, python-format
msgid "Invalid result type: %(result_type)s"
msgstr "Tipo de resultado no válido: %(result_type)s"
msgstr "Tipo de resultado inválido: %(result_type)s"
#, python-format
msgid "Invalid rolling_type: %(type)s"
@@ -5665,7 +5677,7 @@ msgstr "Tipo móvil no válido: %(type)s "
#, python-format
msgid "Invalid spatial point encountered: %(latlong)s"
msgstr "Se ha encontrado un punto espacial no válido: %(latlong)s"
msgstr "Se encontró un punto espacial inválido: %(latlong)s"
msgid "Invalid state."
msgstr "Estado no válido."
@@ -6451,7 +6463,7 @@ msgid "Middle"
msgstr "Medio"
msgid "Midnight"
msgstr "Medianoche"
msgstr "Media noche"
msgid "Miles"
msgstr "Millas"
@@ -6562,7 +6574,7 @@ msgstr "Mes"
#, python-format
msgid "Months %s"
msgstr "Meses %s "
msgstr "Meses %s"
msgid "More"
msgstr "Más"
@@ -6577,7 +6589,7 @@ msgid "Move only"
msgstr "Solo mover"
msgid "Moves the given set of dates by a specified interval."
msgstr "Mueve el conjunto de fechas en cuestión por un intervalo especificado."
msgstr "Desplaza el conjunto de fechas dado en un intervalo especificado."
msgid "Multi-Dimensions"
msgstr "Multidimensional"
@@ -6797,7 +6809,7 @@ msgid "No entities have this tag currently assigned"
msgstr "Ninguna entidad tiene esta etiqueta asignada actualmente"
msgid "No filter"
msgstr "No hay ningún filtro"
msgstr "Sin filtro"
msgid "No filter is selected."
msgstr "No se ha seleccionado ningún filtro."
@@ -6821,7 +6833,7 @@ msgid "No records found"
msgstr "No se han encontrado registros"
msgid "No results"
msgstr "No hay resultados"
msgstr "Sin resultados"
msgid "No results found"
msgstr "No se han encontrado resultados"
@@ -6830,7 +6842,7 @@ msgid "No results match your filter criteria"
msgstr "No hay resultados que coincidan con tus criterios de filtro"
msgid "No results were returned for this query"
msgstr "No se han devuelto resultados para esta consulta"
msgstr "No se obtuvieron resultados para esta consulta"
msgid ""
"No results were returned for this query. If you expected results to be "
@@ -7073,7 +7085,7 @@ msgid "One or many metrics to display"
msgstr "Una o varias métricas a mostrar"
msgid "One or more annotation layers failed loading."
msgstr "No se han podido cargar una o más capas de anotación."
msgstr "Una o más capas de anotación fallaron al cargar."
msgid "One or more columns already exist"
msgstr "Una o más columnas ya existen"
@@ -7489,7 +7501,7 @@ msgid "Pie Chart"
msgstr "Gráfico tipo pastel"
msgid "Pie charts on a map"
msgstr "Gráficos tipo pastel en un mapa"
msgstr "Mapa con gráficos tipo pastel"
msgid "Pie shape"
msgstr "Forma de pastel"
@@ -7590,7 +7602,6 @@ msgstr "Vuelve a introducir tu contraseña."
msgid "Please re-export your file and try importing again"
msgstr "Vuelve a exportar tu archivo e intenta importarlo de nuevo"
#, fuzzy
msgid "Please reach out to the Chart Owner for assistance."
msgid_plural "Please reach out to the Chart Owners for assistance."
msgstr[0] "Ponte en contacto con el propietario del gráfico para obtener ayuda."
@@ -8007,7 +8018,7 @@ msgid "Relationships between community channels"
msgstr "Relaciones entre canales comunitarios"
msgid "Relative Date/Time"
msgstr "Fecha/hora relativa"
msgstr "Fecha/Hora Relativa"
msgid "Relative period"
msgstr "Periodo relativo"
@@ -8752,6 +8763,9 @@ msgstr "Selecciona el método de entrega"
msgid "Select Tags"
msgstr "Seleccionar etiquetas"
msgid "Select Viz Type"
msgstr "Selecciona un tipo de visualización"
msgid "Select chart type"
msgstr "Seleccionar tipo de visualización"
@@ -9141,12 +9155,21 @@ msgstr "Mostrar burbujas"
msgid "Show CREATE VIEW statement"
msgstr "Mostrar instrucción CREAR VISTA"
msgid "Show cell bars"
msgstr "Mostrar barras de celda"
msgid "Show Cell bars"
msgstr "Todos los gráficos"
msgid "Show Chart"
msgstr "Mostrar Gráfico"
msgid "Show Column"
msgstr "Mostrar Columna"
msgid "Show Dashboard"
msgstr "Mostrar el panel de control"
msgid "Show Database"
msgstr "Mostrar Base de Datos"
msgid "Show Labels"
msgstr "Mostrar etiquetas"
@@ -9156,6 +9179,9 @@ msgstr "Mostrar registro"
msgid "Show Markers"
msgstr "Mostrar marcadores"
msgid "Show Metric"
msgstr "Mostrar Métrica"
msgid "Show Metric Names"
msgstr "Mostrar nombres de las métricas"
@@ -9423,7 +9449,7 @@ msgstr "Lo sentimos, se ha producido un error. Inténtalo de nuevo más tarde."
#, python-format
msgid "Sorry, there was an error saving this %s: %s"
msgstr "Lo sentimos, se ha producido un error al guardar este %s: %s"
msgstr "Lo sentimos, se ha producido un error al guardar esto %s: %s"
#, python-format
msgid "Sorry, there was an error saving this dashboard: %s"
@@ -9512,7 +9538,7 @@ msgid "Spatial"
msgstr "Espacial"
msgid "Specific Date/Time"
msgstr "Fecha/hora específica"
msgstr "Fecha/Hora Específica"
msgid "Specify name to CREATE TABLE AS schema in: public"
msgstr "Especifica el nombre para el esquema CREAR TABLA COMO en: público"
@@ -9577,7 +9603,7 @@ msgid "Start date"
msgstr "Fecha de inicio"
msgid "Start date included in time range"
msgstr "Fecha de inicio incluida en el intervalo de tiempo"
msgstr "Fecha inicial incluida en el rango de tiempo"
msgid "Start y-axis at 0"
msgstr "Iniciar eje Y en 0"
@@ -9733,6 +9759,9 @@ msgstr "Documentación del SDK integrado de Superset."
msgid "Superset chart"
msgstr "Gráfico Superset"
msgid "Superset dashboard"
msgstr "Dashboard Superset"
msgid "Superset encountered an error while running a command."
msgstr "Superset ha encontrado un error al ejecutar un comando."
@@ -9850,7 +9879,7 @@ msgstr "No se ha definido el nombre de la tabla"
#, python-format
msgid "Table or View \"%(table)s\" does not exist."
msgstr "La tabla o la vista «%(table)s» no existen."
msgstr "La tabla o vista \"%(table)s\" no existe"
msgid ""
"Table that visualizes paired t-tests, which are used to understand "
@@ -10717,7 +10746,9 @@ msgid "There was an error loading the tables"
msgstr "Se ha producido un error al cargar las tablas"
msgid "There was an error retrieving dashboard tabs."
msgstr "Se ha producido un error al recuperar las pestañas del panel."
msgstr ""
"Lo sentimos, hubo un error al obtener la información de la base de datos:"
" %s"
#, python-format
msgid "There was an error saving the favorite status: %s"
@@ -10736,7 +10767,7 @@ msgstr "Ha habido un problema al eliminar %s: %s"
#, python-format
msgid "There was an issue deleting rules: %s"
msgstr "Ha habido un problema al eliminar las reglas: %s"
msgstr "Hubo un problema eliminando las reglas: %s"
#, python-format
msgid "There was an issue deleting the selected %s: %s"
@@ -10795,11 +10826,11 @@ msgstr "Ha habido un problema al recuperar tu gráfico: %s "
#, python-format
msgid "There was an issue fetching your dashboards: %s"
msgstr "Ha habido un problema al recuperar tus paneles de control: %s"
msgstr "Hubo un problema al obtener tus dashboards: %s"
#, python-format
msgid "There was an issue fetching your recent activity: %s"
msgstr "Ha habido un problema al recuperar tu actividad reciente: %s"
msgstr "Hubo un error al obtener tu actividad reciente: %s"
#, python-format
msgid "There was an issue fetching your saved queries: %s"
@@ -10839,7 +10870,7 @@ msgid "This action will permanently delete the template."
msgstr "Esta acción eliminará permanentemente la plantilla."
msgid "This action will permanently delete the user."
msgstr "Esta acción eliminará permanentemente el uduario."
msgstr "Esta acción eliminará permanentemente el usuario."
msgid ""
"This can be either an IP address (e.g. 127.0.0.1) or a domain name (e.g. "
@@ -11048,7 +11079,6 @@ msgstr "Este tipo de visualización no admite el filtro cruzado."
msgid "This visualization type is not supported."
msgstr "Este tipo de visualización no se admite."
#, fuzzy
msgid "This was triggered by:"
msgid_plural "This may be triggered by:"
msgstr[0] "La causa de esto ha sido:"
@@ -11327,7 +11357,33 @@ msgid "Tree layout"
msgstr "Diseño del árbol"
msgid "Tree orientation"
msgstr "Orientación del árbol"
Findings (brief):
- No git merge conflict markers found (no <<<<<<< / ======= / >>>>>>>).
- PO header mismatch: "Language: en" this is an Spanish file; set to "es".
- Duplicate msgid entries with conflicting/empty translations:
- " at line %(line)d" one entry has " en la línea %(line)d", another has an empty msgstr.
- "Dashboard cannot be copied due to invalid parameters." appears multiple times with different/empty msgstr values.
- "%(subtitle)s\nThis may be triggered by:\n %(issue)s" msgstr is empty in one occurrence.
- There are other repeated msgids with one occurrence left untranslated (examples: search for repeated msgid strings with one msgstr == "").
- Empty translations (examples):
- msgid "%(subtitle)s\nThis may be triggered by:\n %(issue)s" msgstr "".
- Several other msgid entries have msgstr "" (scan for msgstr "" occurrences).
- Fuzzy entries present (e.g. entries annotated "#, fuzzy") these need review and removal of the fuzzy flag after correction.
- Typo in a translation: msgid "This action will permanently delete the user." msgstr contains "uduario." (should be "usuario.").
Recommended next steps:
- Fix header Language to "es".
- Remove/fix duplicate msgids: consolidate into a single entry and keep the correct translation.
- Fill in missing msgstr values (or mark as untranslated intentionally).
- Review and resolve fuzzy entries, then remove the "fuzzy" flag.
- Fix obvious typos (e.g., "uduario" "usuario").
If you want, I can produce a patch that:
- updates header Language to "es",
- removes duplicate entries by keeping the first translated occurrence,
- lists all msgids with empty msgstr for you to translate,
or show exact locations (line ranges) for each problem. Which would you prefer?
msgid "Treemap"
msgstr "Diagrama de árbol"
@@ -11983,7 +12039,7 @@ msgstr "WMS"
#, python-format
msgid "Waiting on %s"
msgstr "Esperando a %s"
msgstr "Esperando por %s"
msgid "Waiting on database..."
msgstr "Esperando a la base de datos..."
@@ -12083,7 +12139,7 @@ msgstr "Semanas %s"
msgid "Weight"
msgstr "Peso"
#, fuzzy, python-format
#, python-format
msgid ""
"Were having trouble loading these results. Queries are set to timeout "
"after %s second."
@@ -12093,7 +12149,7 @@ msgid_plural ""
msgstr[0] "Estamos teniendo problemas para cargar estos resultados. Se considera que una consulta ha superado el tiempo de espera después de %s segundo."
msgstr[1] "Estamos teniendo problemas para cargar estos resultados. Se considera que una consulta ha superado el tiempo de espera después de %s segundos."
#, fuzzy, python-format
#, python-format
msgid ""
"Were having trouble loading this visualization. Queries are set to "
"timeout after %s second."
@@ -12576,7 +12632,7 @@ msgstr "No puedes utilizar el diseño de marca de 45° con el filtro de interval
#, python-format
msgid "You do not have permission to edit this %s"
msgstr "No tienes permisos para editar este %s"
msgstr "No tienes permisos para editar esto %s"
msgid "You do not have permission to edit this chart"
msgstr "No tienes permisos para editar este gráfico"
@@ -12841,7 +12897,7 @@ msgid "background"
msgstr "fondo"
msgid "Basic conditional formatting"
msgstr "formato condicional básico"
msgstr "Formato condicional básico"
msgid "basis"
msgstr "base"
@@ -13164,13 +13220,18 @@ msgid ""
"is linked to %s charts that appear on %s dashboards and users have %s SQL"
" Lab tabs using this database open. Are you sure you want to continue? "
"Deleting the database will break those objects."
msgstr "está vinculado a %s gráficos que aparecen en %s paneles de control y los usuarios tienen %s pestañas de SQL Lab usando esta base de datos abierta. ¿Seguro que quieres continuar? Eliminar la base de datos descompondrá esos objetos."
msgstr ""
"La base de datos %s está vinculada a %s gráficos que aparecen en %s "
"dashboards. ¿Estás seguro de que quieres continuar? Eliminar la base de "
"datos dejará inutilizables esos objetos."
#, python-format
msgid ""
"is linked to %s charts that appear on %s dashboards. Are you sure you "
"want to continue? Deleting the dataset will break those objects."
msgstr "está vinculado a %s gráficos que aparecen en %s paneles de control. ¿Seguro que quieres continuar? Eliminar el conjunto de datos descompondrá esos objetos."
msgstr ""
"esta linkeado a %s gráficos que aparecen en %s tableros. ¿Está seguro"
"de que desea continuar? Eliminar el conjunto de datos romperá esos objetos."
msgid "is not"
msgstr "no es"
@@ -13310,16 +13371,19 @@ msgid "pixels"
msgstr "píxeles"
msgid "previous calendar month"
msgstr "mes natural anterior"
msgstr "mes anterior"
msgid "previous calendar quarter"
msgstr "trimestre natural anterior"
msgstr "trimestre anterior"
msgid "previous calendar week"
msgstr "semana natural anterior"
msgstr "semana anterior"
msgid "previous calendar year"
msgstr "año natural anterior"
msgstr "año anterior"
msgid "published"
msgstr "No publicado"
msgid "quarter"
msgstr "trimestre"
@@ -13339,6 +13403,9 @@ msgstr "reiniciar"
msgid "recent"
msgstr "reciente"
msgid "recents"
msgstr "Recientes"
msgid "recipients"
msgstr "destinatarios"
@@ -13360,6 +13427,9 @@ msgstr "rowlevelsecurity"
msgid "running"
msgstr "en ejecución"
msgid "saved queries"
msgstr "Consultas Guardadas"
msgid "save"
msgstr "guardar"
+14 -4
View File
@@ -477,10 +477,20 @@ def get_since_until( # pylint: disable=too-many-arguments,too-many-locals,too-m
)
if time_shift:
time_delta_since = parse_past_timedelta(time_shift, _since)
time_delta_until = parse_past_timedelta(time_shift, _until)
_since = _since if _since is None else (_since - time_delta_since)
_until = _until if _until is None else (_until - time_delta_until)
separator = " : "
if separator in time_shift:
# Date range format: parse as a new time range
parts = time_shift.split(separator, 1)
if len(parts) != 2:
raise ValueError(f"Invalid time_shift format: {time_shift}")
since_part, until_part = (part.strip() for part in parts)
_since = parse_human_datetime(since_part)
_until = parse_human_datetime(until_part)
else:
time_delta_since = parse_past_timedelta(time_shift, _since)
time_delta_until = parse_past_timedelta(time_shift, _until)
_since = _since if _since is None else (_since - time_delta_since)
_until = _until if _until is None else (_until - time_delta_until)
if instant_time_comparison_range:
# This is only set using the new time comparison controls
+30 -38
View File
@@ -25,6 +25,9 @@ from PIL import Image
logger = logging.getLogger(__name__)
# Time to wait after scrolling for content to settle and load (in milliseconds)
SCROLL_SETTLE_TIMEOUT_MS = 1000
if TYPE_CHECKING:
try:
from playwright.sync_api import Page
@@ -77,7 +80,7 @@ def combine_screenshot_tiles(screenshot_tiles: list[bytes]) -> bytes:
def take_tiled_screenshot(
page: "Page", element_name: str, viewport_height: int = 2000
page: "Page", element_name: str, tile_height: int
) -> bytes | None:
"""
Take a tiled screenshot of a large dashboard by scrolling and capturing sections.
@@ -85,7 +88,7 @@ def take_tiled_screenshot(
Args:
page: Playwright page object
element_name: CSS class name of the element to screenshot
viewport_height: Height of each tile in pixels
tile_height: Height of each tile in pixels
Returns:
Combined screenshot bytes or None if failed
@@ -100,17 +103,17 @@ def take_tiled_screenshot(
const el = document.querySelector(".{element_name}");
const rect = el.getBoundingClientRect();
return {{
width: el.scrollWidth,
height: el.scrollHeight,
top: rect.top + window.scrollY,
left: rect.left + window.scrollX,
width: el.scrollWidth
top: rect.top + window.scrollY,
}};
}}""")
dashboard_height = element_info["height"]
dashboard_top = element_info["top"]
dashboard_left = element_info["left"]
dashboard_width = element_info["width"]
dashboard_height = element_info["height"]
dashboard_left = element_info["left"]
dashboard_top = element_info["top"]
logger.info(
"Dashboard: %sx%spx at (%s, %s)",
@@ -121,62 +124,54 @@ def take_tiled_screenshot(
)
# Calculate number of tiles needed
num_tiles = max(1, (dashboard_height + viewport_height - 1) // viewport_height)
num_tiles = max(1, (dashboard_height + tile_height - 1) // tile_height)
logger.info("Taking %s screenshot tiles", num_tiles)
screenshot_tiles = []
for i in range(num_tiles):
# Calculate scroll position to show this tile's content
scroll_y = dashboard_top + (i * viewport_height)
scroll_y = dashboard_top + (i * tile_height)
# Scroll the window to the desired position
page.evaluate(f"window.scrollTo(0, {scroll_y})")
logger.debug(
"Scrolled window to %s for tile %s/%s", scroll_y, i + 1, num_tiles
)
# Wait for scroll to settle and content to load
page.wait_for_timeout(2000) # 2 second wait per tile
# Get the current element position after scroll
current_element_box = page.evaluate(f"""() => {{
const el = document.querySelector(".{element_name}");
const rect = el.getBoundingClientRect();
return {{
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height
}};
}}""")
page.wait_for_timeout(SCROLL_SETTLE_TIMEOUT_MS)
# Calculate what portion of the element we want to capture for this tile
tile_start_in_element = i * viewport_height
tile_start_in_element = i * tile_height
remaining_content = dashboard_height - tile_start_in_element
tile_content_height = min(viewport_height, remaining_content)
# Determine clip height (use visible element height in viewport)
clip_height = min(tile_content_height, current_element_box["height"])
clip_height = min(tile_height, remaining_content)
clip_y = (
0
if tile_height < remaining_content
else tile_height - remaining_content
)
clip_x = dashboard_left
# Skip tile if dimensions are invalid (width or height <= 0)
# This can happen if element is completely scrolled out of viewport
if clip_height <= 0:
if clip_height <= 0 or clip_y < 0:
logger.warning(
"Skipping tile %s/%s due to invalid clip dimensions: "
"width=%s, height=%s (element may be scrolled out of viewport)",
"x=%s, y=%s, width=%s, height=%s "
"(element may be scrolled out of viewport)",
i + 1,
num_tiles,
current_element_box["width"],
clip_x,
clip_y,
dashboard_width,
clip_height,
)
continue
# Clip to capture only the current tile portion of the element
clip = {
"x": current_element_box["x"],
"y": current_element_box["y"],
"width": current_element_box["width"],
"x": clip_x,
"y": clip_y,
"width": dashboard_width,
"height": clip_height,
}
@@ -190,9 +185,6 @@ def take_tiled_screenshot(
logger.info("Combining screenshot tiles...")
combined_screenshot = combine_screenshot_tiles(screenshot_tiles)
# Reset window scroll position
page.evaluate("window.scrollTo(0, 0)")
return combined_screenshot
except Exception as e:
+11 -7
View File
@@ -239,11 +239,13 @@ class WebDriverPlaywright(WebDriverProxy):
browser_args = app.config["WEBDRIVER_OPTION_ARGS"]
browser = playwright.chromium.launch(args=browser_args)
pixel_density = app.config["WEBDRIVER_WINDOW"].get("pixel_density", 1)
viewport_height = self._window[1]
viewport_width = self._window[0]
context = browser.new_context(
bypass_csp=True,
viewport={
"height": self._window[1],
"width": self._window[0],
"height": viewport_height,
"width": viewport_width,
},
device_scale_factor=pixel_density,
)
@@ -343,15 +345,15 @@ class WebDriverPlaywright(WebDriverProxy):
height_threshold = app.config.get(
"SCREENSHOT_TILED_HEIGHT_THRESHOLD", 5000
)
viewport_height = app.config.get(
"SCREENSHOT_TILED_VIEWPORT_HEIGHT", self._window[1]
tile_height = app.config.get(
"SCREENSHOT_TILED_VIEWPORT_HEIGHT", viewport_height
)
# Use tiled screenshots for large dashboards
use_tiled = (
chart_count >= chart_threshold
or dashboard_height > height_threshold
)
) and dashboard_height > tile_height
if use_tiled:
logger.info(
@@ -360,9 +362,11 @@ class WebDriverPlaywright(WebDriverProxy):
chart_count,
dashboard_height,
)
img = take_tiled_screenshot(
page, element_name, viewport_height=viewport_height
# set viewport height to tile height for easier calculations
page.set_viewport_size(
{"height": tile_height, "width": viewport_width}
)
img = take_tiled_screenshot(page, element_name, tile_height)
if img is None:
logger.warning(
(
@@ -753,10 +753,11 @@ class TestPostChartDataApi(BaseTestChartDataApi):
@with_feature_flags(GLOBAL_ASYNC_QUERIES=True)
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_chart_data_async_cached_sync_response(self):
@mock.patch("superset.extensions.event_logger.log")
def test_chart_data_async_cached_sync_response(self, mock_event_logger):
"""
Chart data API: Test chart data query returns results synchronously
when results are already cached.
when results are already cached, and that is_cached is logged.
"""
app._got_first_request = False
async_query_manager_factory.init_app(app)
@@ -767,7 +768,7 @@ class TestPostChartDataApi(BaseTestChartDataApi):
cmd_run_val = {
"query_context": QueryContext(),
"queries": [{"query": "select * from foo"}],
"queries": [{"query": "select * from foo", "is_cached": True}],
}
with mock.patch.object(
@@ -780,7 +781,16 @@ class TestPostChartDataApi(BaseTestChartDataApi):
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
patched_run.assert_called_once_with(force_cached=True)
assert data == {"result": [{"query": "select * from foo"}]}
assert data == {
"result": [{"query": "select * from foo", "is_cached": True}]
}
# Verify that is_cached was logged to event logger
call_kwargs = mock_event_logger.call_args[1]
records = call_kwargs.get("records", [])
assert len(records) > 0
# is_cached should be True when retrieved from cache in async path
assert records[0]["is_cached"] is True
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@mock.patch("superset.extensions.event_logger.log")
@@ -20,8 +20,10 @@ from uuid import uuid4
import pytest
from flask import current_app
from sqlalchemy.orm.exc import StaleDataError
from superset.commands.dashboard.permalink.create import CreateDashboardPermalinkCommand
from superset.commands.report.exceptions import ReportScheduleUnexpectedError
from superset.commands.report.execute import AsyncExecuteReportScheduleCommand
from superset.models.dashboard import Dashboard
from superset.reports.models import ReportSourceFormat
@@ -118,3 +120,40 @@ def test_report_with_header_data(
assert header_data.get("notification_source") == ReportSourceFormat.DASHBOARD
assert header_data.get("notification_type") == report_schedule.type
assert len(send_email_smtp_mock.call_args.kwargs["header_data"]) == 8
@patch("superset.reports.notifications.email.send_email_smtp")
@patch("superset.commands.report.execute.DashboardScreenshot")
@pytest.mark.usefixtures("login_as_admin")
def test_report_schedule_stale_data_error_preserves_cause(
dashboard_screenshot_mock: MagicMock,
send_email_smtp_mock: MagicMock,
tabbed_dashboard: Dashboard, # noqa: F811
) -> None:
"""
Test that when db.session.commit raises StaleDataError during logging,
we surface ReportScheduleUnexpectedError while preserving the original
StaleDataError as the cause.
"""
dashboard_screenshot_mock.get_screenshot.return_value = b"test-image"
current_app.config["ALERT_REPORTS_NOTIFICATION_DRY_RUN"] = False
with create_dashboard_report(
dashboard=tabbed_dashboard,
extra={},
name="test stale data error",
) as report_schedule:
# Mock db.session.commit to raise StaleDataError during log creation
with patch("superset.db.session.commit") as mock_commit:
mock_commit.side_effect = StaleDataError("test stale data")
# Execute the report and expect ReportScheduleUnexpectedError
with pytest.raises(ReportScheduleUnexpectedError) as exc_info:
AsyncExecuteReportScheduleCommand(
str(uuid4()), report_schedule.id, datetime.utcnow()
).run()
# Verify the original StaleDataError is preserved as the cause
assert exc_info.value.__cause__ is not None
assert isinstance(exc_info.value.__cause__, StaleDataError)
assert str(exc_info.value.__cause__) == "test stale data"
+35
View File
@@ -126,6 +126,41 @@ class TestSqlLab(SupersetTestCase):
"engine_name": engine_name,
}
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_sql_json_where_clause_content_type(self):
"""
Test that queries with WHERE clauses return proper Content-Type headers.
This test addresses issue #36072 where Flask 2.3+ content negotiation
could cause HTTP 406 errors for queries with WHERE clauses, particularly
when using ENABLE_PROXY_FIX or certain Accept header configurations.
"""
self.login(ADMIN_USERNAME)
# Test query with WHERE clause
resp = self.client.post(
"/api/v1/sqllab/execute/",
json={
"database_id": self.get_database_by_name("examples").id,
"sql": "SELECT * FROM birth_names WHERE name = 'John' LIMIT 5",
"client_id": "test_where_1",
},
)
# Verify response is successful
assert resp.status_code in (200, 202), f"Expected 200/202, got {resp.status_code}"
# Verify Content-Type header is explicitly set to prevent 406 errors
assert "application/json" in resp.headers.get("Content-Type", "")
# Verify response body is valid JSON
data = resp.json
assert isinstance(data, dict)
# If query ran synchronously (200), verify it has data
if resp.status_code == 200:
assert "data" in data or "query_id" in data
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_sql_json_dml_disallowed(self):
self.login(ADMIN_USERNAME)
@@ -0,0 +1,16 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
@@ -0,0 +1,325 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from unittest.mock import Mock, patch
import pytest
from superset.commands.chart.data.get_data_command import ChartDataCommand
from superset.commands.chart.exceptions import ChartDataQueryFailedError
from superset.common.chart_data import ChartDataResultType
from superset.common.query_context import QueryContext
def test_query_result_type_allows_validation_error_payload() -> None:
"""
Regression test: Ensure result_type='query' with error payload returns
the error instead of raising ChartDataQueryFailedError.
This locks in the behavior where validation errors are passed through
to the frontend for display in ViewQueryModal.
Context:
- GitHub Issue #35492
- Superset 4.1.3 allowed errors to pass through
- Command reorganization in 2023 broke this behavior
- This test ensures errors pass through for query-only requests
"""
# Mock QueryContext with result_type=QUERY
mock_query_context = Mock(spec=QueryContext)
mock_query_context.result_type = ChartDataResultType.QUERY
mock_query_context.get_payload.return_value = {
"queries": [{"error": "Missing temporal column", "language": "sql"}]
}
command = ChartDataCommand(mock_query_context)
# Should NOT raise - this is the key assertion for the regression test
result = command.run()
# Verify error is passed through in the response
assert result["queries"][0]["error"] == "Missing temporal column"
assert result["queries"][0]["language"] == "sql"
assert "query" not in result["queries"][0] # No SQL for validation errors
def test_full_result_type_raises_on_error() -> None:
"""
Test that result_type='full' with error raises ChartDataQueryFailedError.
This ensures data requests continue to fail fast when errors occur,
maintaining existing behavior for non-query requests.
"""
# Mock QueryContext with result_type=FULL
mock_query_context = Mock(spec=QueryContext)
mock_query_context.result_type = ChartDataResultType.FULL
mock_query_context.get_payload.return_value = {
"queries": [{"error": "Invalid column name"}]
}
command = ChartDataCommand(mock_query_context)
# Should raise exception for data requests
with pytest.raises(ChartDataQueryFailedError) as exc_info:
command.run()
assert "Invalid column name" in str(exc_info.value)
def test_results_result_type_raises_on_error() -> None:
"""
Test that result_type='results' with error raises ChartDataQueryFailedError.
Ensures fail-fast behavior is preserved for results-only requests.
"""
# Mock QueryContext with result_type=RESULTS
mock_query_context = Mock(spec=QueryContext)
mock_query_context.result_type = ChartDataResultType.RESULTS
mock_query_context.get_payload.return_value = {
"queries": [{"error": "Database connection failed"}]
}
command = ChartDataCommand(mock_query_context)
# Should raise exception for results requests
with pytest.raises(ChartDataQueryFailedError) as exc_info:
command.run()
assert "Database connection failed" in str(exc_info.value)
def test_query_result_type_returns_successful_query() -> None:
"""
Test that result_type='query' without error returns query successfully.
Ensures no regression for successful query requests.
"""
# Mock QueryContext with result_type=QUERY and successful query
mock_query_context = Mock(spec=QueryContext)
mock_query_context.result_type = ChartDataResultType.QUERY
mock_query_context.get_payload.return_value = {
"queries": [{"query": "SELECT * FROM table", "language": "sql"}]
}
command = ChartDataCommand(mock_query_context)
# Should return query successfully
result = command.run()
assert result["queries"][0]["query"] == "SELECT * FROM table"
assert result["queries"][0]["language"] == "sql"
assert "error" not in result["queries"][0]
def test_full_result_type_returns_successful_data() -> None:
"""
Test that result_type='full' without error returns data successfully.
Ensures no regression for successful data requests.
"""
# Mock QueryContext with result_type=FULL and successful data
mock_query_context = Mock(spec=QueryContext)
mock_query_context.result_type = ChartDataResultType.FULL
mock_query_context.get_payload.return_value = {
"queries": [{"data": [{"col1": "value1"}], "colnames": ["col1"]}]
}
command = ChartDataCommand(mock_query_context)
# Should return data successfully
result = command.run()
assert result["queries"][0]["data"] == [{"col1": "value1"}]
assert result["queries"][0]["colnames"] == ["col1"]
assert "error" not in result["queries"][0]
def test_query_result_type_with_multiple_queries_and_mixed_results() -> None:
"""
Test that result_type='query' handles multiple queries with mixed results.
Some queries may succeed while others have validation errors.
All should be returned without raising exceptions.
"""
# Mock QueryContext with multiple queries
mock_query_context = Mock(spec=QueryContext)
mock_query_context.result_type = ChartDataResultType.QUERY
mock_query_context.get_payload.return_value = {
"queries": [
{"query": "SELECT * FROM table1", "language": "sql"},
{"error": "Missing required field", "language": "sql"},
{"query": "SELECT * FROM table2", "language": "sql"},
]
}
command = ChartDataCommand(mock_query_context)
# Should return all queries without raising
result = command.run()
# Verify first query succeeded
assert result["queries"][0]["query"] == "SELECT * FROM table1"
assert "error" not in result["queries"][0]
# Verify second query has error
assert result["queries"][1]["error"] == "Missing required field"
assert "query" not in result["queries"][1]
# Verify third query succeeded
assert result["queries"][2]["query"] == "SELECT * FROM table2"
assert "error" not in result["queries"][2]
def test_full_result_type_fails_fast_on_first_error_in_multiple_queries() -> None:
"""
Test that result_type='full' raises on first error even with multiple queries.
Ensures fail-fast behavior when multiple queries are present.
"""
# Mock QueryContext with multiple queries where first has error
mock_query_context = Mock(spec=QueryContext)
mock_query_context.result_type = ChartDataResultType.FULL
mock_query_context.get_payload.return_value = {
"queries": [
{"error": "First query failed"},
{"data": [{"col1": "value1"}]},
]
}
command = ChartDataCommand(mock_query_context)
# Should raise on first error without processing remaining queries
with pytest.raises(ChartDataQueryFailedError) as exc_info:
command.run()
assert "First query failed" in str(exc_info.value)
def test_get_query_catches_parsing_error() -> None:
"""
Test that _get_query() catches SupersetParseError and returns both SQL and error.
When SQL generation succeeds but optimization/parsing fails:
- SQL has already been compiled (stored in error.extra['sql'])
- Error message describes the parsing failure
- Both should be returned to the frontend for display
"""
from superset.common.query_actions import _get_query
from superset.common.query_object import QueryObject
from superset.exceptions import SupersetParseError
# Create mock query_context and query_obj
mock_query_context = Mock()
mock_query_obj = Mock(spec=QueryObject)
# Create SupersetParseError with SQL in error.extra
parse_error = SupersetParseError(
sql="SELECT SUM ( Open", # SQL was generated before parsing failed
engine="postgresql",
message="Error parsing near 'Open' at line 1:17",
line=1,
column=17,
)
# Mock _get_datasource and datasource.get_query_str to raise the error
with patch("superset.common.query_actions._get_datasource") as mock_get_ds:
mock_datasource = Mock()
mock_datasource.query_language = "sql"
mock_datasource.get_query_str.side_effect = parse_error
mock_get_ds.return_value = mock_datasource
# GREEN: Exception is caught, values returned (new behavior after fix)
result = _get_query(mock_query_context, mock_query_obj, False)
# Should return both query (from error.extra['sql']) and error message
assert result["query"] == "SELECT SUM ( Open"
assert result["error"] == "Error parsing near 'Open' at line 1:17"
assert result["language"] == "sql"
def test_get_query_handles_parsing_error_with_missing_sql_key() -> None:
"""
Test _get_query() when error.extra exists but 'sql' key is missing.
Edge case: error.extra = {"other_field": "value"} with no 'sql' key.
Should NOT set result["query"] - prevents null from reaching TypeScript.
Should still return error message for display.
Ensures defensive programming when extracting SQL from exception extra data.
"""
from superset.common.query_actions import _get_query
from superset.common.query_object import QueryObject
from superset.exceptions import SupersetParseError
mock_query_context = Mock()
mock_query_obj = Mock(spec=QueryObject)
parse_error = SupersetParseError(
sql="SELECT * FROM table",
message="Parsing error occurred",
)
# Mock error.extra to NOT have sql key
parse_error.error.extra = {"other_field": "some_value"}
with patch("superset.common.query_actions._get_datasource") as mock_get_ds:
mock_datasource = Mock()
mock_datasource.query_language = "sql"
mock_datasource.get_query_str.side_effect = parse_error
mock_get_ds.return_value = mock_datasource
result = _get_query(mock_query_context, mock_query_obj, False)
assert "query" not in result
assert result["error"] == "Parsing error occurred"
assert result["language"] == "sql"
def test_get_query_handles_parsing_error_with_null_sql_value() -> None:
"""
Test _get_query() when error.extra has 'sql': None explicitly set.
Edge case: error.extra = {"sql": None} with sql key present but value is None.
Should NOT set result["query"] - prevents null from reaching TypeScript.
Should still return error message for display.
Ensures defensive programming when extracting SQL from exception extra data.
"""
from superset.common.query_actions import _get_query
from superset.common.query_object import QueryObject
from superset.exceptions import SupersetParseError
mock_query_context = Mock()
mock_query_obj = Mock(spec=QueryObject)
parse_error = SupersetParseError(
sql="SELECT * FROM table",
message="Parsing error occurred",
)
# Mock error.extra to have sql key with None value
parse_error.error.extra = {"sql": None}
with patch("superset.common.query_actions._get_datasource") as mock_get_ds:
mock_datasource = Mock()
mock_datasource.query_language = "sql"
mock_datasource.get_query_str.side_effect = parse_error
mock_get_ds.return_value = mock_datasource
result = _get_query(mock_query_context, mock_query_obj, False)
assert "query" not in result
assert result["error"] == "Parsing error occurred"
assert result["language"] == "sql"
@@ -482,7 +482,7 @@ class TestExecuteSql:
}
async with Client(mcp_server) as client:
with pytest.raises(ToolError, match="minimum of 1"):
with pytest.raises(ToolError, match="greater than or equal to 1"):
await client.call_tool("execute_sql", {"request": request})
# Test limit too high
@@ -493,5 +493,5 @@ class TestExecuteSql:
}
async with Client(mcp_server) as client:
with pytest.raises(ToolError, match="maximum of 10000"):
with pytest.raises(ToolError, match="less than or equal to 10000"):
await client.call_tool("execute_sql", {"request": request})
@@ -0,0 +1,55 @@
# 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.
"""Tests for health_check MCP tool."""
from superset.mcp_service.system.schemas import HealthCheckResponse
def test_health_check_response_schema():
"""Test that HealthCheckResponse has required fields."""
response = HealthCheckResponse(
status="healthy",
timestamp="2025-11-10T19:00:00",
service="Test MCP Service",
version="4.0.0",
python_version="3.11.0",
platform="Darwin",
)
assert response.status == "healthy"
assert response.service == "Test MCP Service"
assert response.version == "4.0.0"
assert response.python_version == "3.11.0"
assert response.platform == "Darwin"
assert response.timestamp is not None
assert response.uptime_seconds is None # Optional field
def test_health_check_response_with_uptime():
"""Test HealthCheckResponse with optional uptime field."""
response = HealthCheckResponse(
status="healthy",
timestamp="2025-11-10T19:00:00",
service="Test MCP Service",
version="4.0.0",
python_version="3.11.0",
platform="Darwin",
uptime_seconds=123.45,
)
assert response.uptime_seconds == 123.45
+33
View File
@@ -29,6 +29,8 @@ from sqlalchemy import create_engine
from sqlalchemy.orm.session import Session
from sqlalchemy.pool import StaticPool
from superset.superset_typing import AdhocColumn
if TYPE_CHECKING:
from superset.models.core import Database
@@ -1125,3 +1127,34 @@ def test_process_select_expression_end_to_end(database: Database) -> None:
assert expected.replace(" ", "").lower() in result.replace(" ", "").lower(), (
f"Expected '{expected}' to be in result '{result}' for input '{expression}'"
)
def test_adhoc_column_to_sqla_with_column_reference(database: Database) -> None:
"""
Test that adhoc_column_to_sqla
properly quotes column identifiers when isColumnReference is true.
This tests the fix for column names with spaces being properly quoted
before being processed by SQLGlot to prevent "column AS alias" misinterpretation.
"""
from superset.connectors.sqla.models import SqlaTable
table = SqlaTable(
table_name="test_table",
database=database,
)
# Test 1: Column reference with spaces should be quoted
col_with_spaces: AdhocColumn = {
"sqlExpression": "Customer Name",
"label": "Customer Name",
"isColumnReference": True,
}
result = table.adhoc_column_to_sqla(col_with_spaces)
# Should contain the quoted column name
assert result is not None
result_str = str(result)
assert '"Customer Name"' in result_str
@@ -271,6 +271,15 @@ def test_get_since_until() -> None:
expected = datetime(1999, 12, 25), datetime(2017, 12, 25)
assert result == expected
# Test time_shift with date range format (contains ' : ')
result = get_since_until(
time_range="today : tomorrow",
time_shift="yesterday : today",
)
# When time_shift contains ' : ', it should be parsed as a new time range
expected = datetime(2016, 11, 6), datetime(2016, 11, 7)
assert result == expected
with pytest.raises(ValueError): # noqa: PT011
get_since_until(time_range="tomorrow : yesterday")
+104 -154
View File
@@ -23,6 +23,7 @@ from PIL import Image
from superset.utils.screenshot_utils import (
combine_screenshot_tiles,
SCROLL_SETTLE_TIMEOUT_MS,
take_tiled_screenshot,
)
@@ -114,22 +115,11 @@ class TestTakeTiledScreenshot:
element = MagicMock()
page.locator.return_value = element
# Mock element info - simulating a 5000px tall dashboard
# Mock element info - simulating a 5000px tall dashboard at position 100
element_info = {"height": 5000, "top": 100, "left": 50, "width": 800}
element_box = {"x": 50, "y": 200, "width": 800, "height": 600}
# For 3 tiles (5000px / 2000px = 2.5, rounded up to 3):
# 1 initial call + 3 scroll + 3 element box + 1 reset scroll = 8 calls
page.evaluate.side_effect = [
element_info, # Initial call for dashboard dimensions
None, # First scroll call
element_box, # First element box call
None, # Second scroll call
element_box, # Second element box call
None, # Third scroll call
element_box, # Third element box call
None, # Final reset scroll call
]
# Only one evaluate call needed for dashboard dimensions
page.evaluate.return_value = element_info
# Mock screenshot method
fake_screenshot = b"fake_screenshot_data"
@@ -144,7 +134,7 @@ class TestTakeTiledScreenshot:
) as mock_combine:
mock_combine.return_value = b"combined_screenshot"
result = take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
result = take_tiled_screenshot(mock_page, "dashboard", tile_height=2000)
# Should return combined screenshot
assert result == b"combined_screenshot"
@@ -163,7 +153,7 @@ class TestTakeTiledScreenshot:
element.wait_for.side_effect = Exception("Element not found")
mock_page.locator.return_value = element
result = take_tiled_screenshot(mock_page, "nonexistent", viewport_height=2000)
result = take_tiled_screenshot(mock_page, "nonexistent", tile_height=2000)
assert result is None
@@ -171,97 +161,25 @@ class TestTakeTiledScreenshot:
"""Test that tiles are calculated correctly."""
# Mock dashboard height of 3500px with viewport of 2000px
element_info = {"height": 3500, "top": 100, "left": 50, "width": 800}
element_box = {"x": 50, "y": 200, "width": 800, "height": 600}
# For 2 tiles (3500px / 2000px = 1.75, rounded up to 2):
# 1 initial call + 2 scroll + 2 element box + 1 reset scroll = 6 calls
mock_page.evaluate.side_effect = [
element_info,
None, # First scroll call
element_box, # First element box call
None, # Second scroll call
element_box, # Second element box call
None, # Reset scroll call
]
# Override the fixture's evaluate return for this test
mock_page.evaluate.return_value = element_info
with patch(
"superset.utils.screenshot_utils.combine_screenshot_tiles"
) as mock_combine:
mock_combine.return_value = b"combined"
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
take_tiled_screenshot(mock_page, "dashboard", tile_height=2000)
# Should take 2 screenshots (3500px / 2000px = 1.75, rounded up to 2)
assert mock_page.screenshot.call_count == 2
def test_scroll_positions_calculated_correctly(self, mock_page):
"""Test that scroll positions are calculated correctly."""
# Override the fixture's side_effect for this specific test
element_info = {"height": 5000, "top": 100, "left": 50, "width": 800}
element_box = {"x": 50, "y": 200, "width": 800, "height": 600}
mock_page.evaluate.side_effect = [
element_info, # Initial call for dashboard dimensions
None, # First scroll call
element_box, # First element box call
None, # Second scroll call
element_box, # Second element box call
None, # Third scroll call
element_box, # Third element box call
None, # Reset scroll call
]
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
# Check scroll positions (dashboard_top = 100)
scroll_calls = [
call
for call in mock_page.evaluate.call_args_list
if "scrollTo" in str(call)
]
# Should have scrolled to positions: 100, 2100, 4100
expected_scrolls = [
"window.scrollTo(0, 100)",
"window.scrollTo(0, 2100)",
"window.scrollTo(0, 4100)",
]
actual_scrolls = [call[0][0] for call in scroll_calls]
assert len(actual_scrolls) == 4 # 3 tile scrolls + 1 reset
for expected in expected_scrolls:
assert expected in actual_scrolls
def test_reset_scroll_position(self, mock_page):
"""Test that scroll position is reset after screenshot."""
# Override the fixture's side_effect for this specific test
element_info = {"height": 5000, "top": 100, "left": 50, "width": 800}
element_box = {"x": 50, "y": 200, "width": 800, "height": 600}
mock_page.evaluate.side_effect = [
element_info, # Initial call for dashboard dimensions
None, # First scroll call
element_box, # First element box call
None, # Second scroll call
element_box, # Second element box call
None, # Third scroll call
element_box, # Third element box call
None, # Reset scroll call
]
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
# Check that final call resets scroll to top
final_call = mock_page.evaluate.call_args_list[-1]
assert "window.scrollTo(0, 0)" in str(final_call)
def test_logs_dashboard_info(self, mock_page):
"""Test that dashboard info is logged."""
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
take_tiled_screenshot(mock_page, "dashboard", tile_height=2000)
# Should log dashboard dimensions with lazy logging format
mock_logger.info.assert_any_call(
@@ -276,7 +194,7 @@ class TestTakeTiledScreenshot:
mock_page.locator.side_effect = Exception("Unexpected error")
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
result = take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
result = take_tiled_screenshot(mock_page, "dashboard", tile_height=2000)
assert result is None
# The exception object is passed, not the string
@@ -284,67 +202,44 @@ class TestTakeTiledScreenshot:
assert call_args[0][0] == "Tiled screenshot failed: %s"
assert str(call_args[0][1]) == "Unexpected error"
def test_wait_timeouts_between_tiles(self, mock_page):
"""Test that there are appropriate waits between tiles."""
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
# Should have called wait_for_timeout for each tile (3 tiles)
assert mock_page.wait_for_timeout.call_count == 3
# Each wait should be 2000ms (2 seconds)
for call in mock_page.wait_for_timeout.call_args_list:
assert call[0][0] == 2000
def test_screenshot_clip_parameters(self, mock_page):
"""Test that screenshot clipping parameters are correct."""
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
take_tiled_screenshot(mock_page, "dashboard", tile_height=2000)
# Check screenshot calls have correct clip parameters
screenshot_calls = mock_page.screenshot.call_args_list
for call in screenshot_calls:
# Should have 3 tiles (5000px / 2000px = 2.5, rounded up to 3)
assert len(screenshot_calls) == 3
# All tiles use the same x and width
for _, call in enumerate(screenshot_calls):
kwargs = call[1]
assert kwargs["type"] == "png"
assert "clip" in kwargs
assert kwargs["clip"]["x"] == 50
assert kwargs["clip"]["width"] == 800
clip = kwargs["clip"]
assert clip["x"] == 50
assert clip["y"] == 200
assert clip["width"] == 800
# Height should be min of viewport_height and remaining content
assert clip["height"] <= 600 # Element height from mock
# Check y positions and heights for each tile
# Tile 1: clip_y=0, height=2000 (tile_height < remaining: 5000)
assert screenshot_calls[0][1]["clip"]["y"] == 0
assert screenshot_calls[0][1]["clip"]["height"] == 2000
def test_skips_tiles_with_zero_height(self):
"""Test that tiles with zero height are skipped."""
mock_page = MagicMock()
# Tile 2: clip_y=0, height=2000 (tile_height < remaining: 3000)
assert screenshot_calls[1][1]["clip"]["y"] == 0
assert screenshot_calls[1][1]["clip"]["height"] == 2000
# Mock element locator
element = MagicMock()
mock_page.locator.return_value = element
# Tile 3: clip_y=1000 (tile_height - remaining: 2000 - 1000)
# height=1000 (remaining content)
assert screenshot_calls[2][1]["clip"]["y"] == 1000
assert screenshot_calls[2][1]["clip"]["height"] == 1000
# Mock element info - 4000px tall dashboard
def test_handles_invalid_tile_dimensions(self, mock_page):
"""Test that tiles with invalid dimensions are skipped."""
# Mock a dashboard where the last tile would have 0 or negative height
# This simulates edge cases in height calculations
element_info = {"height": 4000, "top": 100, "left": 50, "width": 800}
# First tile: valid clip region
valid_element_box = {"x": 50, "y": 200, "width": 800, "height": 600}
# Second tile: element scrolled completely out (zero height)
invalid_element_box = {"x": 50, "y": -100, "width": 800, "height": 0}
# For 2 tiles (4000px / 2000px = 2):
# 1 initial + 2 scroll + 2 element box + 1 reset = 6 calls
mock_page.evaluate.side_effect = [
element_info, # Initial call for dashboard dimensions
None, # First scroll call
valid_element_box, # First element box (valid)
None, # Second scroll call
invalid_element_box, # Second element box (zero height)
None, # Reset scroll call
]
mock_page.screenshot.return_value = b"fake_screenshot"
mock_page.evaluate.return_value = element_info
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
with patch(
@@ -352,21 +247,76 @@ class TestTakeTiledScreenshot:
) as mock_combine:
mock_combine.return_value = b"combined"
result = take_tiled_screenshot(
mock_page, "dashboard", viewport_height=2000
)
# Use exact viewport height that divides evenly
result = take_tiled_screenshot(mock_page, "dashboard", tile_height=2000)
# Should still succeed with partial tiles
# Should succeed
assert result == b"combined"
# Should only take 1 screenshot (second tile skipped)
assert mock_page.screenshot.call_count == 1
# Should take 2 screenshots (4000px / 2000px = 2)
assert mock_page.screenshot.call_count == 2
# Should log warning about skipped tile
mock_logger.warning.assert_called_once()
warning_call = mock_logger.warning.call_args
# Check the format string
assert "invalid clip dimensions" in warning_call[0][0]
# Check the arguments (tile 2/2)
assert warning_call[0][1] == 2 # tile number (i + 1)
assert warning_call[0][2] == 2 # num_tiles
# Should not log any warnings about invalid dimensions
warning_calls = [
call
for call in mock_logger.warning.call_args_list
if "invalid clip dimensions" in str(call)
]
assert len(warning_calls) == 0
def test_skips_tile_with_zero_height(self, mock_page):
"""Test that a tile with zero or negative height is skipped."""
# This test verifies the clip_height <= 0 check
# We'll manually test the logic by creating a scenario where
# remaining_content becomes <= 0
element_info = {"height": 2000, "top": 100, "left": 50, "width": 800}
mock_page.evaluate.return_value = element_info
with patch(
"superset.utils.screenshot_utils.combine_screenshot_tiles"
) as mock_combine:
mock_combine.return_value = b"combined"
# Use viewport height equal to element height
result = take_tiled_screenshot(mock_page, "dashboard", tile_height=2000)
# Should succeed with 1 tile
assert result == b"combined"
assert mock_page.screenshot.call_count == 1
def test_scroll_positions_calculated_correctly(self, mock_page):
"""Test that window scroll positions are calculated correctly."""
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(mock_page, "dashboard", tile_height=2000)
# Check page.evaluate calls for scrolling
# First call is for dimensions, subsequent are for scrolling
evaluate_calls = mock_page.evaluate.call_args_list
# Should have 1 dimension query + 3 scroll calls
assert len(evaluate_calls) == 4
# First call is for dimensions (contains querySelector)
assert "querySelector" in str(evaluate_calls[0])
# Subsequent calls are scroll positions
# Tile 1: scroll to y=100 (dashboard_top + 0 * tile_height)
assert evaluate_calls[1][0][0] == "window.scrollTo(0, 100)"
# Tile 2: scroll to y=2100 (dashboard_top + 1 * tile_height)
assert evaluate_calls[2][0][0] == "window.scrollTo(0, 2100)"
# Tile 3: scroll to y=4100 (dashboard_top + 2 * tile_height)
assert evaluate_calls[3][0][0] == "window.scrollTo(0, 4100)"
def test_reset_scroll_position(self, mock_page):
"""Test that scroll position waits are called after each scroll."""
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(mock_page, "dashboard", tile_height=2000)
# Should call wait_for_timeout 3 times (once per tile)
assert mock_page.wait_for_timeout.call_count == 3
# Each wait should use the scroll settle timeout constant
for call in mock_page.wait_for_timeout.call_args_list:
assert call[0][0] == SCROLL_SETTLE_TIMEOUT_MS