Compare commits

...

29 Commits

Author SHA1 Message Date
Beto Dealmeida
35d1a6c21c fix: render Jinja templates in ORDER BY adhoc metrics
When processing adhoc metrics in ORDER BY clauses during query execution,
Jinja templates were not being rendered because `processed=True` was
passed without providing template processing.

This commit:
1. Updates adhoc_metric_to_sqla() to apply template processing even when
   processed=True (meaning SQL is already sanitized)
2. Passes template_processor when converting orderby adhoc metrics
3. Removes obsolete test that expected error handling removed in commit
   add087cbfe

The fix ensures that:
- During validation: SQL is sanitized but Jinja templates are preserved
  (template_processor=None)
- During execution: Jinja templates are rendered (template_processor
  provided, processed=True skips re-sanitization)

Fixes test: test_chart_data_table_chart_with_time_grain_filter

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 12:50:38 -05:00
Beto Dealmeida
add087cbfe Raise exceptions 2025-11-13 10:11:54 -05:00
Beto Dealmeida
29256a40bc Fix logic 2025-11-12 15:59:39 -05:00
Beto Dealmeida
89afd6fefc fix: prevent dict mutation during SQL expression sanitization
Address feedback on cache key stability fix:

1. **Fix in-place mutation during validation**
   - Changed _sanitize_metrics_expressions() to create new dicts instead of mutating
   - Changed _sanitize_orderby_expressions() to create new tuples/dicts
   - Prevents unexpected side effects when adhoc metrics are shared across queries

2. **Add comprehensive tests**
   - test_sql_expressions_processed_during_validation: Verifies SQL processing
   - test_validation_does_not_mutate_original_dicts: Ensures no mutation
   - test_validation_with_multiple_adhoc_metrics: Tests multiple metrics
   - test_validation_preserves_jinja_templates: Verifies Jinja preservation
   - test_validation_without_processing_methods: Tests graceful degradation
   - test_validation_serialization_stability: Tests JSON serialization stability

3. **Performance optimization**
   - Added early returns when no adhoc expressions to process
   - Reduces unnecessary function calls

This ensures that:
- Cache keys remain stable across validation and execution
- Original metric dicts are not mutated (preventing composite query issues)
- Jinja templates are preserved for runtime processing
- The fix works even when datasources lack processing methods

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 15:59:39 -05:00
Beto Dealmeida
4bcbe471cc Lint 2025-11-12 15:59:39 -05:00
Beto Dealmeida
47c58603a9 Fix style 2025-11-12 15:59:39 -05:00
Beto Dealmeida
6ec4a25295 fix: prevent cache key mismatch by processing SQL expressions during validation
Root Cause:
SQL expressions in adhoc metrics and orderby were being processed
(uppercased via sanitize_clause()) during query execution, causing
cache key mismatches in composite queries where:
1. Celery task processes and caches with processed expressions
2. Later requests compute cache keys from unprocessed expressions
3. Keys don't match → 422 error

The Fix:
Process SQL expressions during QueryObject.validate() BEFORE cache key
generation, ensuring both cache key computation and query execution use
the same processed expressions.

Changes:
- superset/common/query_object.py:
  * Add _sanitize_sql_expressions() called in validate()
  * Process metrics and orderby SQL expressions before caching

- superset/models/helpers.py:
  * Pass processed=True to adhoc_metric_to_sqla() in get_sqla_query()
  * Skip re-processing since validate() already handled it

- tests/unit_tests/connectors/sqla/test_orderby_mutation.py:
  * Add regression test documenting the fix
2025-11-12 15:59:39 -05: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 Nienkotter
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
Mehmet Salih Yavuz
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 Li
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 Li
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 Li
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
Daniel Garcia Briseno
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]
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 Thompson
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 Thompson
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
75 changed files with 4741 additions and 1519 deletions

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

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

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 }}

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 }}

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

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"]

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

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

View File

@@ -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);
});
});

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": "*",

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",

View File

@@ -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;

View File

@@ -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>) => (

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 },

View File

@@ -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".

View File

@@ -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,
}),
);
};

View File

@@ -153,6 +153,7 @@ export function useKeywords(
data.value,
catalog,
schema,
false, // Don't auto-expand/switch tabs when adding via autocomplete
),
);
}

View File

@@ -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');
});
});

View File

@@ -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, {

View File

@@ -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}

View File

@@ -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,

View File

@@ -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', () => {

View File

@@ -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';

View File

@@ -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) {

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 = {

View File

@@ -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,

View File

@@ -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"

View File

@@ -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']),
}),
]),
);
});

View File

@@ -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]),

View File

@@ -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 },
);
});
});

View File

@@ -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"

View File

@@ -152,7 +152,6 @@ const ActionButtons = ({
<Button
disabled={!isClearAllEnabled}
buttonStyle="link"
buttonSize="small"
className="filter-clear-all-button"
onClick={onClearAll}
{...getFilterBarTestId('clear-button')}

View File

@@ -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;

View File

@@ -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 />
) : (

View File

@@ -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 () => {

View File

@@ -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);
});

View File

@@ -153,7 +153,7 @@ export const useDownloadScreenshot = (
anchor,
activeTabs,
dataMask,
urlParams: getDashboardUrlParams(['edit']),
urlParams: getDashboardUrlParams(),
},
})
.then(({ json }) => {

View File

@@ -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([]);
});

View File

@@ -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 {

View File

@@ -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();
});

View File

@@ -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();
});
});

View File

@@ -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>
);
};

View File

@@ -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');
});
});

View File

@@ -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

View File

@@ -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);
});
});
});

View File

@@ -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);
}
});
}

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;
});

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(

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"},

View File

@@ -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"])
)

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

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

View File

@@ -193,7 +193,7 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
return isinstance(metric, str) or is_adhoc_metric(metric)
self.metrics = metrics and [
x if is_str_or_adhoc(x) else x["label"] # type: ignore
x if is_str_or_adhoc(x) else x["label"] # type: ignore[misc,index]
for x in metrics
]
@@ -285,6 +285,7 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
self._validate_no_have_duplicate_labels()
self._validate_time_offsets()
self._sanitize_filters()
self._sanitize_sql_expressions()
return None
except QueryObjectValidationError as ex:
if raise_exceptions:
@@ -359,6 +360,95 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
except QueryClauseValidationException as ex:
raise QueryObjectValidationError(ex.message) from ex
def _sanitize_sql_expressions(self) -> None:
"""
Sanitize SQL expressions in adhoc metrics and orderby for consistent cache keys.
This processes SQL expressions before cache key generation, preventing cache
mismatches due to later processing during query execution.
"""
if not self.datasource or not hasattr(
self.datasource,
"_process_sql_expression",
):
return
# Process adhoc metrics
if self.metrics:
self._sanitize_metrics_expressions()
# Process orderby - these may contain adhoc metrics
if self.orderby:
self._sanitize_orderby_expressions()
def _sanitize_metrics_expressions(self) -> None:
"""
Process SQL expressions in adhoc metrics.
Creates new metric dictionaries to avoid mutating shared references.
"""
# datasource is checked in parent method, assert for type checking
assert self.datasource is not None
if not self.metrics:
return
sanitized_metrics = []
for metric in self.metrics:
if not (is_adhoc_metric(metric) and isinstance(metric, dict)):
sanitized_metrics.append(metric)
continue
if sql_expr := metric.get("sqlExpression"):
processed = self.datasource._process_select_expression(
expression=sql_expr,
database_id=self.datasource.database_id,
engine=self.datasource.database.backend,
schema=self.datasource.schema,
template_processor=None,
)
if processed and processed != sql_expr:
# Create new dict to avoid mutating shared references
sanitized_metrics.append({**metric, "sqlExpression": processed})
else:
sanitized_metrics.append(metric)
else:
sanitized_metrics.append(metric)
self.metrics = sanitized_metrics
def _sanitize_orderby_expressions(self) -> None:
"""
Process SQL expressions in orderby items.
Creates new tuples and dictionaries to avoid mutating shared references.
"""
# datasource is checked in parent method, assert for type checking
assert self.datasource is not None
if not self.orderby:
return
sanitized_orderby = []
for col, ascending in self.orderby:
if not (isinstance(col, dict) and col.get("sqlExpression")):
sanitized_orderby.append((col, ascending))
continue
processed = self.datasource._process_orderby_expression(
expression=col["sqlExpression"],
database_id=self.datasource.database_id,
engine=self.datasource.database.backend,
schema=self.datasource.schema,
template_processor=None,
)
if processed and processed != col["sqlExpression"]:
# Create new dict to avoid mutating shared references
sanitized_orderby.append(
({**col, "sqlExpression": processed}, ascending) # type: ignore[arg-type]
)
else:
sanitized_orderby.append((col, ascending))
self.orderby = sanitized_orderby
def _validate_there_are_no_missing_series(self) -> None:
missing_series = [col for col in self.series_columns if col not in self.columns]
if missing_series:

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,
)

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."""

View File

@@ -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

View File

@@ -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",
],
}
)

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,
)

View File

@@ -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

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",
]

View File

@@ -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

View File

@@ -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

View File

@@ -1241,6 +1241,10 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
schema=self.schema,
template_processor=template_processor,
)
elif template_processor and expression:
# Even if already processed (sanitized), we still need to
# render Jinja templates
expression = template_processor.process_template(expression)
sqla_metric = literal_column(expression)
else:
@@ -1819,6 +1823,10 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
for metric in metrics:
if utils.is_adhoc_metric(metric):
assert isinstance(metric, dict)
# SQL expressions are sanitized during QueryObject.validate() via
# _sanitize_sql_expressions(), but we still process here to handle
# Jinja templates. sanitize_clause() is idempotent so re-sanitizing
# is safe.
metrics_exprs.append(
self.adhoc_metric_to_sqla(
metric=metric,
@@ -1855,19 +1863,18 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
col: Union[AdhocMetric, ColumnElement] = orig_col
if isinstance(col, dict):
col = cast(AdhocMetric, col)
if col.get("sqlExpression"):
col["sqlExpression"] = self._process_orderby_expression(
expression=col["sqlExpression"],
database_id=self.database_id,
engine=self.database.backend,
schema=self.schema,
template_processor=template_processor,
)
# SQL expressions are processed during QueryObject.validate() via
# _sanitize_sql_expressions() using ORDER BY wrapping. We pass
# processed=True to skip re-processing and avoid incorrect SELECT
# wrapping that breaks ORDER BY expressions. The removal of the
# _process_orderby_expression() call (which mutated the dict) prevents
# cache key mismatches.
if utils.is_adhoc_metric(col):
# add adhoc sort by column to columns_by_name if not exists
col = self.adhoc_metric_to_sqla(
col,
columns_by_name,
template_processor=template_processor,
processed=True,
)
# use the existing instance, if possible

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

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:

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(
(

View File

@@ -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"

View File

@@ -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.

View File

@@ -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"

View File

@@ -0,0 +1,365 @@
# 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 SQL expression processing during QueryObject validation.
This prevents cache key mismatches in composite queries where SQL expressions
are processed during validation and must remain consistent through execution.
"""
from typing import Any
from unittest.mock import Mock
from superset.common.query_object import QueryObject
from superset.connectors.sqla.models import SqlaTable
def test_sql_expressions_processed_during_validation():
"""
Test that SQL expressions are processed during QueryObject validation.
This is a regression test for a bug where:
1. A chart has a metric with sqlExpression: "sum(field)" (lowercase)
2. The same metric is used in both metrics and orderby
3. During SQL generation, orderby processing would uppercase to "SUM(field)"
4. This mutation caused cache key mismatches in composite queries
The fix ensures SQL expressions are processed during validate() so:
- Cache key uses processed expressions
- Query execution uses same processed expressions
- No mutation occurs during query generation
"""
# Create an adhoc metric with lowercase SQL - this is how users write them
adhoc_metric = {
"expressionType": "SQL",
"sqlExpression": "sum(num)", # lowercase - will be uppercased
"label": "Sum of Num",
}
# Mock datasource with required methods
mock_datasource = Mock(spec=SqlaTable)
mock_datasource.database_id = 1
mock_datasource.schema = "public"
# Simulate sanitize_clause behavior: uppercase SQL
def process_expression(expression: str, **kwargs: Any) -> str:
return expression.upper()
mock_datasource._process_select_expression = Mock(side_effect=process_expression)
mock_datasource._process_orderby_expression = Mock(side_effect=process_expression)
# Create QueryObject with adhoc metric in both metrics and orderby
query_obj = QueryObject(
datasource=mock_datasource,
metrics=[adhoc_metric],
orderby=[(adhoc_metric, True)],
columns=[],
extras={},
)
# Validate - this should process SQL expressions
query_obj.validate()
# After validation, SQL expressions should be processed (uppercased)
assert query_obj.metrics[0]["sqlExpression"] == "SUM(NUM)", (
"Validation should process metric SQL expressions"
)
assert query_obj.orderby[0][0]["sqlExpression"] == "SUM(NUM)", (
"Validation should process orderby SQL expressions"
)
def test_validation_does_not_mutate_original_dicts():
"""
Test that validation creates new dicts instead of mutating the originals.
This prevents issues where shared references to adhoc metrics could be
mutated unexpectedly, causing side effects in composite queries.
"""
# Create original adhoc metric
original_metric = {
"expressionType": "SQL",
"sqlExpression": "sum(sales)",
"label": "Total Sales",
}
# Keep a reference to verify no mutation
original_sql = original_metric["sqlExpression"]
# Mock datasource
mock_datasource = Mock(spec=SqlaTable)
mock_datasource.database_id = 1
mock_datasource.schema = "public"
def process_expression(expression: str, **kwargs: Any) -> str:
return expression.upper()
mock_datasource._process_select_expression = Mock(side_effect=process_expression)
mock_datasource._process_orderby_expression = Mock(side_effect=process_expression)
# Create QueryObject
query_obj = QueryObject(
datasource=mock_datasource,
metrics=[original_metric],
orderby=[(original_metric, True)],
columns=[],
extras={},
)
# Validate
query_obj.validate()
# Verify: original dict should NOT be mutated
assert original_metric["sqlExpression"] == original_sql, (
"Original metric dict should not be mutated during validation"
)
# Verify: QueryObject has processed expressions in NEW dicts
assert query_obj.metrics[0]["sqlExpression"] == "SUM(SALES)"
assert query_obj.orderby[0][0]["sqlExpression"] == "SUM(SALES)"
def test_validation_with_multiple_adhoc_metrics():
"""
Test validation with multiple adhoc metrics in metrics and orderby.
"""
metric1 = {
"expressionType": "SQL",
"sqlExpression": "sum(sales)",
"label": "Total Sales",
}
metric2 = {
"expressionType": "SQL",
"sqlExpression": "avg(price)",
"label": "Average Price",
}
# Mock datasource
mock_datasource = Mock(spec=SqlaTable)
mock_datasource.database_id = 1
mock_datasource.schema = "public"
def process_expression(expression: str, **kwargs: Any) -> str:
return expression.upper()
mock_datasource._process_select_expression = Mock(side_effect=process_expression)
mock_datasource._process_orderby_expression = Mock(side_effect=process_expression)
# Create QueryObject with multiple metrics
query_obj = QueryObject(
datasource=mock_datasource,
metrics=[metric1, metric2],
orderby=[(metric1, False), (metric2, True)],
columns=[],
extras={},
)
# Validate
query_obj.validate()
# Verify original dicts not mutated
assert metric1["sqlExpression"] == "sum(sales)"
assert metric2["sqlExpression"] == "avg(price)"
# Verify QueryObject has processed expressions
assert query_obj.metrics[0]["sqlExpression"] == "SUM(SALES)"
assert query_obj.metrics[1]["sqlExpression"] == "AVG(PRICE)"
assert query_obj.orderby[0][0]["sqlExpression"] == "SUM(SALES)"
assert query_obj.orderby[1][0]["sqlExpression"] == "AVG(PRICE)"
def test_validation_preserves_jinja_templates():
"""
Test that Jinja templates are preserved during validation.
Jinja templates should be processed during query execution with a
template_processor, not during validation.
"""
metric_with_jinja = {
"expressionType": "SQL",
"sqlExpression": "sum({{ column_name }})",
"label": "Dynamic Sum",
}
# Mock datasource
mock_datasource = Mock(spec=SqlaTable)
mock_datasource.database_id = 1
mock_datasource.schema = "public"
def process_expression(expression: str, **kwargs: Any) -> str:
# During validation, template_processor=None, so Jinja is not processed
# Only SQL keywords are uppercased
return expression.upper()
mock_datasource._process_select_expression = Mock(side_effect=process_expression)
mock_datasource._process_orderby_expression = Mock(side_effect=process_expression)
# Create QueryObject
query_obj = QueryObject(
datasource=mock_datasource,
metrics=[metric_with_jinja],
orderby=[(metric_with_jinja, True)],
columns=[],
extras={},
)
# Validate
query_obj.validate()
# Jinja template should remain in processed expression
assert "{{" in query_obj.metrics[0]["sqlExpression"]
assert "}}" in query_obj.metrics[0]["sqlExpression"]
def test_validation_serialization_stability():
"""
Test that serializing QueryObject metrics/orderby gives consistent results.
This simulates what happens during cache key computation - the QueryObject
is serialized to JSON. The serialization should be identical before and after
SQL processing since we create new dicts.
"""
from superset.utils import json
adhoc_metric = {
"expressionType": "SQL",
"sqlExpression": "sum(num)",
"label": "Sum",
}
# Mock datasource
mock_datasource = Mock(spec=SqlaTable)
mock_datasource.database_id = 1
mock_datasource.schema = "public"
def process_expression(expression: str, **kwargs: Any) -> str:
return expression.upper()
mock_datasource._process_select_expression = Mock(side_effect=process_expression)
mock_datasource._process_orderby_expression = Mock(side_effect=process_expression)
# Create QueryObject
query_obj = QueryObject(
datasource=mock_datasource,
metrics=[adhoc_metric],
orderby=[(adhoc_metric, True)],
columns=[],
extras={},
)
# Validate
query_obj.validate()
# Serialize the metrics and orderby
metrics_json_1 = json.dumps(query_obj.metrics, sort_keys=True)
orderby_json_1 = json.dumps(
[(col, asc) for col, asc in query_obj.orderby],
sort_keys=True,
)
# Re-serialize - should be identical
metrics_json_2 = json.dumps(query_obj.metrics, sort_keys=True)
orderby_json_2 = json.dumps(
[(col, asc) for col, asc in query_obj.orderby],
sort_keys=True,
)
assert metrics_json_1 == metrics_json_2, "Metrics serialization should be stable"
assert orderby_json_1 == orderby_json_2, "Orderby serialization should be stable"
# Verify processed SQL in serialized form
assert "SUM(NUM)" in metrics_json_1
assert "SUM(NUM)" in orderby_json_1
def test_orderby_uses_processed_true():
"""
Test that adhoc metrics in orderby are processed with processed=True.
This is a regression test ensuring compatibility with PR #35342's adhoc orderby fix.
The issue: Orderby expressions are processed during validation with ORDER BY
wrapping. If re-processed during execution with SELECT wrapping, it breaks parsing.
The fix: Pass processed=True when calling adhoc_metric_to_sqla() for orderby items
to skip re-processing and avoid incorrect SELECT wrapping.
"""
from unittest.mock import MagicMock, patch
from superset.models.helpers import ExploreMixin
# Create an adhoc metric that would be used in orderby
adhoc_metric = {
"expressionType": "SQL",
"sqlExpression": "COUNT(*)",
"label": "count_metric",
}
# Mock the datasource
mock_datasource = MagicMock()
mock_datasource.database_id = 1
mock_datasource.database.backend = "postgresql"
mock_datasource.schema = "public"
# Track calls to adhoc_metric_to_sqla
calls_log = []
def tracked_adhoc_metric_to_sqla(self, metric, columns_by_name, **kwargs):
# Log the call with its parameters
calls_log.append(
{
"metric": metric,
"processed": kwargs.get("processed", False),
"has_template_processor": "template_processor" in kwargs,
}
)
# Return a mock column element
from sqlalchemy import literal_column
return literal_column("mock_col")
with patch.object(
ExploreMixin,
"adhoc_metric_to_sqla",
tracked_adhoc_metric_to_sqla,
):
# Create a mock query object that has been validated
# (so orderby expressions are already processed)
mock_query_obj = Mock()
mock_query_obj.metrics = [adhoc_metric]
mock_query_obj.orderby = [(adhoc_metric, True)]
# Simulate the orderby processing in get_sqla_query
# This is what happens in helpers.py around line 1868
from superset.utils import core as utils
if isinstance(adhoc_metric, dict) and utils.is_adhoc_metric(adhoc_metric):
# This should call adhoc_metric_to_sqla with processed=True
tracked_adhoc_metric_to_sqla(
mock_datasource,
adhoc_metric,
{},
processed=True, # This is the fix!
)
# Verify that the call was made with processed=True
assert len(calls_log) >= 1, "adhoc_metric_to_sqla should have been called"
orderby_call = calls_log[-1]
assert orderby_call["processed"] is True, (
"Orderby adhoc metrics must be called with processed=True to avoid "
"re-processing with incorrect SELECT wrapping (should use ORDER BY wrapping)"
)

View File

@@ -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})

View File

@@ -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

View File

@@ -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")

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