mirror of
https://github.com/apache/superset.git
synced 2026-06-14 03:59:22 +00:00
Compare commits
29 Commits
fix-filter
...
fix-query-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35d1a6c21c | ||
|
|
add087cbfe | ||
|
|
29256a40bc | ||
|
|
89afd6fefc | ||
|
|
4bcbe471cc | ||
|
|
47c58603a9 | ||
|
|
6ec4a25295 | ||
|
|
c244e7f847 | ||
|
|
bb2e2a5ed6 | ||
|
|
a45c0528da | ||
|
|
0b535b792e | ||
|
|
9fbfcf0ccd | ||
|
|
d123249bd2 | ||
|
|
4376476ec4 | ||
|
|
9be61a1245 | ||
|
|
e2e831e322 | ||
|
|
21d585d586 | ||
|
|
0a5144fc1d | ||
|
|
64ca080bb8 | ||
|
|
b85621e9a7 | ||
|
|
e915d7d1d0 | ||
|
|
ae63f64771 | ||
|
|
63dfd95aa2 | ||
|
|
c9f65cf1c2 | ||
|
|
c42e3c6837 | ||
|
|
3167a0dbc0 | ||
|
|
909bd877c9 | ||
|
|
4d0fdba97a | ||
|
|
d2550a525b |
@@ -83,6 +83,7 @@ github:
|
||||
- cypress-matrix (5, chrome)
|
||||
- dependency-review
|
||||
- frontend-build
|
||||
- playwright-tests (chromium)
|
||||
- pre-commit (current)
|
||||
- pre-commit (previous)
|
||||
- test-mysql
|
||||
|
||||
23
.github/workflows/bashlib.sh
vendored
23
.github/workflows/bashlib.sh
vendored
@@ -195,6 +195,7 @@ playwright-install() {
|
||||
|
||||
playwright-run() {
|
||||
local APP_ROOT=$1
|
||||
local TEST_PATH=$2
|
||||
|
||||
# Start Flask from the project root (same as Cypress)
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
@@ -238,8 +239,26 @@ playwright-run() {
|
||||
|
||||
say "::group::Run Playwright tests"
|
||||
echo "Running Playwright with baseURL: ${PLAYWRIGHT_BASE_URL}"
|
||||
npx playwright test auth/login --reporter=github --output=playwright-results
|
||||
local status=$?
|
||||
if [ -n "$TEST_PATH" ]; then
|
||||
# Check if there are any test files in the specified path
|
||||
if ! find "playwright/tests/${TEST_PATH}" -name "*.spec.ts" -type f 2>/dev/null | grep -q .; then
|
||||
echo "No test files found in ${TEST_PATH} - skipping test run"
|
||||
say "::endgroup::"
|
||||
kill $flaskProcessId
|
||||
return 0
|
||||
fi
|
||||
echo "Running tests: ${TEST_PATH}"
|
||||
# Set INCLUDE_EXPERIMENTAL=true to allow experimental tests to run
|
||||
export INCLUDE_EXPERIMENTAL=true
|
||||
npx playwright test "${TEST_PATH}" --output=playwright-results
|
||||
local status=$?
|
||||
# Unset to prevent leaking into subsequent commands
|
||||
unset INCLUDE_EXPERIMENTAL
|
||||
else
|
||||
echo "Running all required tests (experimental/ excluded via playwright.config.ts)"
|
||||
npx playwright test --output=playwright-results
|
||||
local status=$?
|
||||
fi
|
||||
say "::endgroup::"
|
||||
|
||||
# After job is done, print out Flask log for debugging
|
||||
|
||||
115
.github/workflows/superset-e2e.yml
vendored
115
.github/workflows/superset-e2e.yml
vendored
@@ -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 }}
|
||||
|
||||
13
.github/workflows/superset-playwright.yml
vendored
13
.github/workflows/superset-playwright.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,10 +58,16 @@ backoff==2.2.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
backports-tarfile==1.2.0
|
||||
# via jaraco-context
|
||||
bcrypt==4.3.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# paramiko
|
||||
beartype==0.22.5
|
||||
# via
|
||||
# py-key-value-aio
|
||||
# py-key-value-shared
|
||||
billiard==4.2.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -83,10 +89,11 @@ cachelib==0.13.0
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-caching
|
||||
# flask-session
|
||||
cachetools==5.5.2
|
||||
cachetools==6.2.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# google-auth
|
||||
# py-key-value-aio
|
||||
cattrs==25.1.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -167,7 +174,9 @@ cryptography==44.0.3
|
||||
# apache-superset
|
||||
# authlib
|
||||
# paramiko
|
||||
# pyjwt
|
||||
# pyopenssl
|
||||
# secretstorage
|
||||
cycler==0.12.1
|
||||
# via matplotlib
|
||||
cyclopts==3.24.0
|
||||
@@ -188,6 +197,8 @@ deprecation==2.1.0
|
||||
# apache-superset
|
||||
dill==0.4.0
|
||||
# via pylint
|
||||
diskcache==5.6.3
|
||||
# via py-key-value-aio
|
||||
distlib==0.3.8
|
||||
# via virtualenv
|
||||
dnspython==2.7.0
|
||||
@@ -217,7 +228,7 @@ et-xmlfile==2.0.0
|
||||
# openpyxl
|
||||
exceptiongroup==1.3.0
|
||||
# via fastmcp
|
||||
fastmcp==2.10.6
|
||||
fastmcp==2.13.0.2
|
||||
# via apache-superset
|
||||
filelock==3.12.2
|
||||
# via virtualenv
|
||||
@@ -319,7 +330,7 @@ google-api-core==2.23.0
|
||||
# google-cloud-core
|
||||
# pandas-gbq
|
||||
# sqlalchemy-bigquery
|
||||
google-auth==2.40.3
|
||||
google-auth==2.43.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# google-api-core
|
||||
@@ -357,6 +368,7 @@ greenlet==3.1.1
|
||||
# apache-superset
|
||||
# gevent
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
grpcio==1.71.0
|
||||
# via
|
||||
# apache-superset
|
||||
@@ -406,6 +418,8 @@ idna==3.10
|
||||
# requests
|
||||
# trio
|
||||
# url-normalize
|
||||
importlib-metadata==8.7.0
|
||||
# via keyring
|
||||
importlib-resources==6.5.2
|
||||
# via prophet
|
||||
iniconfig==2.0.0
|
||||
@@ -421,6 +435,16 @@ itsdangerous==2.2.0
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask
|
||||
# flask-wtf
|
||||
jaraco-classes==3.4.0
|
||||
# via keyring
|
||||
jaraco-context==6.0.1
|
||||
# via keyring
|
||||
jaraco-functools==4.3.0
|
||||
# via keyring
|
||||
jeepney==0.9.0
|
||||
# via
|
||||
# keyring
|
||||
# secretstorage
|
||||
jinja2==3.1.6
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -439,12 +463,16 @@ jsonschema==4.23.0
|
||||
# openapi-schema-validator
|
||||
# openapi-spec-validator
|
||||
jsonschema-path==0.3.4
|
||||
# via openapi-spec-validator
|
||||
# via
|
||||
# fastmcp
|
||||
# openapi-spec-validator
|
||||
jsonschema-specifications==2025.4.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# jsonschema
|
||||
# openapi-schema-validator
|
||||
keyring==25.6.0
|
||||
# via py-key-value-aio
|
||||
kiwisolver==1.4.7
|
||||
# via matplotlib
|
||||
kombu==5.5.3
|
||||
@@ -496,12 +524,16 @@ matplotlib==3.9.0
|
||||
# via prophet
|
||||
mccabe==0.7.0
|
||||
# via pylint
|
||||
mcp==1.14.1
|
||||
mcp==1.20.0
|
||||
# via fastmcp
|
||||
mdurl==0.1.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# markdown-it-py
|
||||
more-itertools==10.8.0
|
||||
# via
|
||||
# jaraco-classes
|
||||
# jaraco-functools
|
||||
msgpack==1.0.8
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -598,6 +630,8 @@ parsedatetime==2.6
|
||||
# apache-superset
|
||||
pathable==0.4.3
|
||||
# via jsonschema-path
|
||||
pathvalidate==3.3.1
|
||||
# via py-key-value-aio
|
||||
pgsanity==0.2.9
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -612,6 +646,7 @@ pip==25.1.1
|
||||
platformdirs==4.3.8
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# fastmcp
|
||||
# pylint
|
||||
# requests-cache
|
||||
# virtualenv
|
||||
@@ -654,6 +689,10 @@ psutil==6.1.0
|
||||
# via apache-superset
|
||||
psycopg2-binary==2.9.6
|
||||
# via apache-superset
|
||||
py-key-value-aio==0.2.8
|
||||
# via fastmcp
|
||||
py-key-value-shared==0.2.8
|
||||
# via py-key-value-aio
|
||||
pyarrow==16.1.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -709,6 +748,7 @@ pyjwt==2.10.1
|
||||
# apache-superset
|
||||
# flask-appbuilder
|
||||
# flask-jwt-extended
|
||||
# mcp
|
||||
pylint==3.3.7
|
||||
# via apache-superset
|
||||
pynacl==1.5.0
|
||||
@@ -844,6 +884,8 @@ rsa==4.9.1
|
||||
# google-auth
|
||||
ruff==0.8.0
|
||||
# via apache-superset
|
||||
secretstorage==3.4.0
|
||||
# via keyring
|
||||
selenium==4.32.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -941,7 +983,7 @@ trio-websocket==0.12.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# selenium
|
||||
typing-extensions==4.14.0
|
||||
typing-extensions==4.15.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
@@ -950,6 +992,7 @@ typing-extensions==4.14.0
|
||||
# cattrs
|
||||
# exceptiongroup
|
||||
# limits
|
||||
# py-key-value-shared
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
# pyopenssl
|
||||
@@ -1004,6 +1047,8 @@ websocket-client==1.8.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# selenium
|
||||
websockets==15.0.1
|
||||
# via fastmcp
|
||||
werkzeug==3.1.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -1039,6 +1084,8 @@ xlsxwriter==3.0.9
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
# pandas
|
||||
zipp==3.23.0
|
||||
# via importlib-metadata
|
||||
zope-event==5.0
|
||||
# via gevent
|
||||
zope-interface==5.4.0
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { LOGIN } from 'cypress/utils/urls';
|
||||
|
||||
function interceptLogin() {
|
||||
cy.intercept('POST', '**/login/').as('login');
|
||||
}
|
||||
|
||||
describe('Login view', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit(LOGIN);
|
||||
});
|
||||
|
||||
it('should redirect to login with incorrect username and password', () => {
|
||||
interceptLogin();
|
||||
cy.getBySel('login-form').should('be.visible');
|
||||
cy.getBySel('username-input').type('admin');
|
||||
cy.getBySel('password-input').type('wrongpassword');
|
||||
cy.getBySel('login-button').click();
|
||||
cy.wait('@login');
|
||||
cy.url().should('include', LOGIN);
|
||||
});
|
||||
|
||||
it('should login with correct username and password', () => {
|
||||
interceptLogin();
|
||||
cy.getBySel('login-form').should('be.visible');
|
||||
cy.getBySel('username-input').type('admin');
|
||||
cy.getBySel('password-input').type('general');
|
||||
cy.getBySel('login-button').click();
|
||||
cy.wait('@login');
|
||||
cy.getCookies().should('have.length', 1);
|
||||
});
|
||||
});
|
||||
146
superset-frontend/package-lock.json
generated
146
superset-frontend/package-lock.json
generated
@@ -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": "*",
|
||||
|
||||
@@ -241,6 +241,7 @@
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@mihkeleidast/storybook-addon-source": "^1.0.1",
|
||||
"@playwright/test": "^1.56.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.17",
|
||||
"@storybook/addon-actions": "8.1.11",
|
||||
"@storybook/addon-controls": "8.1.11",
|
||||
"@storybook/addon-essentials": "8.1.11",
|
||||
@@ -336,6 +337,7 @@
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-packagejson": "^2.5.19",
|
||||
"process": "^0.11.10",
|
||||
"react-refresh": "^0.14.2",
|
||||
"react-resizable": "^3.0.5",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
"sinon": "^18.0.0",
|
||||
|
||||
@@ -49,7 +49,9 @@ const LoaderWrapper = styled.div<{
|
||||
|
||||
&.inline-centered {
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
&.floating {
|
||||
position: absolute;
|
||||
|
||||
@@ -94,6 +94,11 @@ const StyledTable = styled(Table)<{
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-row.table-row-highlighted > td.ant-table-cell,
|
||||
.ant-table-row.table-row-highlighted > td.ant-table-cell.ant-table-cell-row-hover {
|
||||
background-color: ${theme.colorPrimaryBg};
|
||||
}
|
||||
|
||||
.ant-table-cell {
|
||||
max-width: 320px;
|
||||
font-feature-settings: 'tnum' 1;
|
||||
@@ -155,6 +160,7 @@ function TableCollection<T extends object>({
|
||||
columns,
|
||||
rows,
|
||||
loading,
|
||||
highlightRowId,
|
||||
setSortBy,
|
||||
headerGroups,
|
||||
columnsForWrapText,
|
||||
@@ -272,6 +278,12 @@ function TableCollection<T extends object>({
|
||||
onPageChange,
|
||||
]);
|
||||
|
||||
const getRowClassName = useCallback(
|
||||
(record: Record<string, unknown>) =>
|
||||
record?.id === highlightRowId ? 'table-row-highlighted' : '',
|
||||
[highlightRowId],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledTable
|
||||
loading={loading}
|
||||
@@ -289,6 +301,7 @@ function TableCollection<T extends object>({
|
||||
sortDirections={['ascend', 'descend', 'ascend']}
|
||||
isPaginationSticky={isPaginationSticky}
|
||||
showRowCount={showRowCount}
|
||||
rowClassName={getRowClassName}
|
||||
components={{
|
||||
header: {
|
||||
cell: (props: HTMLAttributes<HTMLTableCellElement>) => (
|
||||
|
||||
@@ -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 },
|
||||
|
||||
70
superset-frontend/playwright/tests/experimental/README.md
Normal file
70
superset-frontend/playwright/tests/experimental/README.md
Normal 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".
|
||||
@@ -950,7 +950,13 @@ export function mergeTable(table, query, prepend) {
|
||||
return { type: MERGE_TABLE, table, query, prepend };
|
||||
}
|
||||
|
||||
export function addTable(queryEditor, tableName, catalogName, schemaName) {
|
||||
export function addTable(
|
||||
queryEditor,
|
||||
tableName,
|
||||
catalogName,
|
||||
schemaName,
|
||||
expanded = true,
|
||||
) {
|
||||
return function (dispatch, getState) {
|
||||
const { dbId } = getUpToDateQuery(getState(), queryEditor, queryEditor.id);
|
||||
const table = {
|
||||
@@ -964,7 +970,7 @@ export function addTable(queryEditor, tableName, catalogName, schemaName) {
|
||||
mergeTable({
|
||||
...table,
|
||||
id: nanoid(11),
|
||||
expanded: true,
|
||||
expanded,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -153,6 +153,7 @@ export function useKeywords(
|
||||
data.value,
|
||||
catalog,
|
||||
schema,
|
||||
false, // Don't auto-expand/switch tabs when adding via autocomplete
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ jest.mock('src/SqlLab/actions/sqlLab', () => ({
|
||||
jest.mock('src/explore/exploreUtils/formData', () => ({
|
||||
postFormData: jest.fn(),
|
||||
}));
|
||||
jest.mock('src/utils/cachedSupersetGet', () => ({
|
||||
...jest.requireActual('src/utils/cachedSupersetGet'),
|
||||
clearDatasetCache: jest.fn(),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('SaveDatasetModal', () => {
|
||||
@@ -336,4 +340,42 @@ describe('SaveDatasetModal', () => {
|
||||
templateParams: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('clears dataset cache when creating new dataset', async () => {
|
||||
const clearDatasetCache = jest.spyOn(
|
||||
require('src/utils/cachedSupersetGet'),
|
||||
'clearDatasetCache',
|
||||
);
|
||||
const postFormData = jest.spyOn(
|
||||
require('src/explore/exploreUtils/formData'),
|
||||
'postFormData',
|
||||
);
|
||||
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({ id: 123 });
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
postFormData.mockResolvedValue('chart_key_123');
|
||||
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
|
||||
const saveConfirmationBtn = screen.getByRole('button', {
|
||||
name: /save/i,
|
||||
});
|
||||
userEvent.click(saveConfirmationBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(clearDatasetCache).toHaveBeenCalledWith(123);
|
||||
});
|
||||
});
|
||||
|
||||
test('clearDatasetCache is imported and available', () => {
|
||||
const clearDatasetCache =
|
||||
require('src/utils/cachedSupersetGet').clearDatasetCache;
|
||||
|
||||
expect(clearDatasetCache).toBeDefined();
|
||||
expect(typeof clearDatasetCache).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@ import { mountExploreUrl } from 'src/explore/exploreUtils';
|
||||
import { postFormData } from 'src/explore/exploreUtils/formData';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { clearDatasetCache } from 'src/utils/cachedSupersetGet';
|
||||
|
||||
interface QueryDatabase {
|
||||
id?: number;
|
||||
@@ -170,6 +171,9 @@ const updateDataset = async (
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
clearDatasetCache(datasetId);
|
||||
|
||||
return data.json.result;
|
||||
};
|
||||
|
||||
@@ -347,15 +351,17 @@ export const SaveDatasetModal = ({
|
||||
datasourceName: datasetName,
|
||||
}),
|
||||
)
|
||||
.then((data: { id: number }) =>
|
||||
postFormData(data.id, 'table', {
|
||||
.then((data: { id: number }) => {
|
||||
clearDatasetCache(data.id);
|
||||
|
||||
return postFormData(data.id, 'table', {
|
||||
...formDataWithDefaults,
|
||||
datasource: `${data.id}__table`,
|
||||
...(defaultVizType === VizType.Table && {
|
||||
all_columns: selectedColumns.map(column => column.column_name),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
});
|
||||
})
|
||||
.then((key: string) => {
|
||||
setLoading(false);
|
||||
const url = mountExploreUrl(null, {
|
||||
|
||||
@@ -36,6 +36,14 @@ const StyledConfigEditor = styled(ConfigEditor)`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledParagraph = styled.p`
|
||||
margin-top: 0;
|
||||
`;
|
||||
|
||||
const Code = styled.code`
|
||||
color: ${({ theme }) => theme.colorPrimary};
|
||||
`;
|
||||
|
||||
export type TemplateParamsEditorProps = {
|
||||
queryEditorId: string;
|
||||
language: 'yaml' | 'json';
|
||||
@@ -65,13 +73,11 @@ const TemplateParamsEditor = ({
|
||||
|
||||
const modalBody = (
|
||||
<div>
|
||||
<p>
|
||||
{t('Assign a set of parameters as')}
|
||||
<code>JSON</code>
|
||||
{t('below (example:')}
|
||||
<code>{'{"my_table": "foo"}'}</code>
|
||||
{t('), and they become available in your SQL (example:')}
|
||||
<code>SELECT * FROM {'{{ my_table }}'} </code>) {t('by using')}
|
||||
<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')}
|
||||
<a
|
||||
href="https://superset.apache.org/sqllab.html#templating-with-jinja"
|
||||
target="_blank"
|
||||
@@ -80,7 +86,7 @@ const TemplateParamsEditor = ({
|
||||
{t('Jinja templating')}
|
||||
</a>{' '}
|
||||
{t('syntax.')}
|
||||
</p>
|
||||
</StyledParagraph>
|
||||
<StyledConfigEditor
|
||||
mode={language}
|
||||
minLines={25}
|
||||
|
||||
@@ -222,8 +222,10 @@ export default function sqlLabReducer(state = {}, action) {
|
||||
}
|
||||
// for new table, associate Id of query for data preview
|
||||
at.dataPreviewQueryId = null;
|
||||
let newState = addToArr(state, 'tables', at, Boolean(action.prepend));
|
||||
newState.activeSouthPaneTab = at.id;
|
||||
let newState = {
|
||||
...addToArr(state, 'tables', at, Boolean(action.prepend)),
|
||||
...(at.expanded && { activeSouthPaneTab: at.id }),
|
||||
};
|
||||
if (action.query) {
|
||||
newState = alterInArr(newState, 'tables', at, {
|
||||
dataPreviewQueryId: action.query.id,
|
||||
|
||||
@@ -370,6 +370,93 @@ describe('sqlLabReducer', () => {
|
||||
newState = sqlLabReducer(newState, action);
|
||||
expect(newState.tables).toHaveLength(0);
|
||||
});
|
||||
test('should set activeSouthPaneTab when adding expanded table', () => {
|
||||
const expandedTable = {
|
||||
...table,
|
||||
id: 'expanded_table_id',
|
||||
name: 'expanded_table',
|
||||
expanded: true,
|
||||
};
|
||||
const action = {
|
||||
type: actions.MERGE_TABLE,
|
||||
table: expandedTable,
|
||||
};
|
||||
newState = sqlLabReducer(initialState, action);
|
||||
expect(newState.tables).toHaveLength(1);
|
||||
expect(newState.activeSouthPaneTab).toBe(expandedTable.id);
|
||||
});
|
||||
test('should not set activeSouthPaneTab when adding collapsed table', () => {
|
||||
const collapsedTable = {
|
||||
...table,
|
||||
id: 'collapsed_table_id',
|
||||
name: 'collapsed_table',
|
||||
expanded: false,
|
||||
};
|
||||
const action = {
|
||||
type: actions.MERGE_TABLE,
|
||||
table: collapsedTable,
|
||||
};
|
||||
newState = sqlLabReducer(initialState, action);
|
||||
expect(newState.tables).toHaveLength(1);
|
||||
expect(newState.activeSouthPaneTab).toBe(initialState.activeSouthPaneTab);
|
||||
});
|
||||
test('should set activeSouthPaneTab when merging existing table with expanded=true', () => {
|
||||
// First add a table with expanded=false
|
||||
const collapsedTable = {
|
||||
...table,
|
||||
id: 'existing_table_id',
|
||||
name: 'existing_table',
|
||||
expanded: false,
|
||||
};
|
||||
const addAction = {
|
||||
type: actions.MERGE_TABLE,
|
||||
table: collapsedTable,
|
||||
};
|
||||
newState = sqlLabReducer(initialState, addAction);
|
||||
const previousActiveSouthPaneTab = newState.activeSouthPaneTab;
|
||||
|
||||
// Now merge the same table with expanded=true
|
||||
const expandedTable = {
|
||||
...collapsedTable,
|
||||
expanded: true,
|
||||
};
|
||||
const mergeAction = {
|
||||
type: actions.MERGE_TABLE,
|
||||
table: expandedTable,
|
||||
};
|
||||
newState = sqlLabReducer(newState, mergeAction);
|
||||
expect(newState.tables).toHaveLength(1);
|
||||
expect(newState.activeSouthPaneTab).toBe(expandedTable.id);
|
||||
expect(newState.activeSouthPaneTab).not.toBe(previousActiveSouthPaneTab);
|
||||
});
|
||||
test('should not set activeSouthPaneTab when merging existing table with expanded=false', () => {
|
||||
// First add a table with expanded=true
|
||||
const expandedTable = {
|
||||
...table,
|
||||
id: 'existing_table_id_2',
|
||||
name: 'existing_table_2',
|
||||
expanded: true,
|
||||
};
|
||||
const addAction = {
|
||||
type: actions.MERGE_TABLE,
|
||||
table: expandedTable,
|
||||
};
|
||||
newState = sqlLabReducer(initialState, addAction);
|
||||
expect(newState.activeSouthPaneTab).toBe(expandedTable.id);
|
||||
|
||||
// Now merge the same table with expanded=false
|
||||
const collapsedTable = {
|
||||
...expandedTable,
|
||||
expanded: false,
|
||||
};
|
||||
const mergeAction = {
|
||||
type: actions.MERGE_TABLE,
|
||||
table: collapsedTable,
|
||||
};
|
||||
newState = sqlLabReducer(newState, mergeAction);
|
||||
expect(newState.tables).toHaveLength(1);
|
||||
expect(newState.activeSouthPaneTab).toBe(expandedTable.id);
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('Run Query', () => {
|
||||
|
||||
@@ -193,6 +193,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
const { json } = await SupersetClient.get({
|
||||
endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
|
||||
});
|
||||
|
||||
addSuccessToast(t('The dataset has been saved'));
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
json.result.type = 'table';
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
resetDatabaseState,
|
||||
} from 'src/database/actions';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { clearDatasetCache } from 'src/utils/cachedSupersetGet';
|
||||
import { DatabaseSelector } from '../../../DatabaseSelector';
|
||||
import CollectionTable from '../CollectionTable';
|
||||
import Fieldset from '../Fieldset';
|
||||
@@ -840,6 +841,9 @@ class DatasourceEditor extends PureComponent {
|
||||
col => !col.expression, // remove calculated columns
|
||||
),
|
||||
});
|
||||
|
||||
clearDatasetCache(datasource.id);
|
||||
|
||||
this.props.addSuccessToast(t('Metadata has been synced'));
|
||||
this.setState({ metadataLoading: false });
|
||||
} catch (error) {
|
||||
|
||||
@@ -103,6 +103,10 @@ export const URL_PARAMS = {
|
||||
name: 'focused_chart',
|
||||
type: 'number',
|
||||
},
|
||||
editMode: {
|
||||
name: 'edit',
|
||||
type: 'boolean',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const RESERVED_CHART_URL_PARAMS: string[] = [
|
||||
@@ -117,6 +121,7 @@ export const RESERVED_DASHBOARD_URL_PARAMS: string[] = [
|
||||
URL_PARAMS.nativeFiltersKey.name,
|
||||
URL_PARAMS.permalinkKey.name,
|
||||
URL_PARAMS.preselectFilters.name,
|
||||
URL_PARAMS.editMode.name,
|
||||
];
|
||||
|
||||
export const DEFAULT_COMMON_BOOTSTRAP_DATA: CommonBootstrapData = {
|
||||
|
||||
@@ -275,6 +275,85 @@ describe('DashboardBuilder', () => {
|
||||
expect(filterbar).toHaveStyleRule('width', `${expectedValue}px`);
|
||||
});
|
||||
|
||||
test('should set header max width based on open filter bar width', () => {
|
||||
const expectedValue = 320;
|
||||
const setter = jest.fn();
|
||||
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
|
||||
expectedValue,
|
||||
setter,
|
||||
]);
|
||||
const nativeFiltersSpy = jest
|
||||
.spyOn(useNativeFiltersModule, 'useNativeFilters')
|
||||
.mockReturnValue({
|
||||
showDashboard: true,
|
||||
missingInitialFilters: [],
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: true,
|
||||
});
|
||||
|
||||
const { getByTestId } = setup();
|
||||
|
||||
expect(getByTestId('dashboard-header-wrapper')).toHaveStyleRule(
|
||||
'max-width',
|
||||
`calc(100vw - ${expectedValue}px)`,
|
||||
);
|
||||
|
||||
nativeFiltersSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should use closed filter bar width when the panel is collapsed', () => {
|
||||
const setter = jest.fn();
|
||||
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
|
||||
OPEN_FILTER_BAR_WIDTH,
|
||||
setter,
|
||||
]);
|
||||
const nativeFiltersSpy = jest
|
||||
.spyOn(useNativeFiltersModule, 'useNativeFilters')
|
||||
.mockReturnValue({
|
||||
showDashboard: true,
|
||||
missingInitialFilters: [],
|
||||
dashboardFiltersOpen: false,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: true,
|
||||
});
|
||||
|
||||
const { getByTestId } = setup();
|
||||
|
||||
expect(getByTestId('dashboard-header-wrapper')).toHaveStyleRule(
|
||||
'max-width',
|
||||
`calc(100vw - ${CLOSED_FILTER_BAR_WIDTH}px)`,
|
||||
);
|
||||
|
||||
nativeFiltersSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should not constrain header width when filter bar is hidden', () => {
|
||||
const setter = jest.fn();
|
||||
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
|
||||
OPEN_FILTER_BAR_WIDTH,
|
||||
setter,
|
||||
]);
|
||||
const nativeFiltersSpy = jest
|
||||
.spyOn(useNativeFiltersModule, 'useNativeFilters')
|
||||
.mockReturnValue({
|
||||
showDashboard: true,
|
||||
missingInitialFilters: [],
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: false,
|
||||
});
|
||||
|
||||
const { getByTestId } = setup();
|
||||
|
||||
expect(getByTestId('dashboard-header-wrapper')).toHaveStyleRule(
|
||||
'max-width',
|
||||
'calc(100vw - 0px)',
|
||||
);
|
||||
|
||||
nativeFiltersSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('filter panel state when featureflag is true', () => {
|
||||
window.featureFlags = {
|
||||
[FeatureFlag.FilterBarClosedByDefault]: true,
|
||||
|
||||
@@ -89,14 +89,14 @@ const StickyPanel = styled.div<{ width: number }>`
|
||||
`;
|
||||
|
||||
// @z-index-above-dashboard-popovers (99) + 1 = 100
|
||||
const StyledHeader = styled.div`
|
||||
${({ theme }) => css`
|
||||
const StyledHeader = styled.div<{ filterBarWidth: number }>`
|
||||
${({ theme, filterBarWidth }) => css`
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 99;
|
||||
max-width: 100vw;
|
||||
max-width: calc(100vw - ${filterBarWidth}px);
|
||||
|
||||
.empty-droptarget:before {
|
||||
position: absolute;
|
||||
@@ -419,6 +419,9 @@ const DashboardBuilder = () => {
|
||||
isReport;
|
||||
|
||||
const [barTopOffset, setBarTopOffset] = useState(0);
|
||||
const [currentFilterBarWidth, setCurrentFilterBarWidth] = useState(
|
||||
CLOSED_FILTER_BAR_WIDTH,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setBarTopOffset(headerRef.current?.getBoundingClientRect()?.height || 0);
|
||||
@@ -516,6 +519,7 @@ const DashboardBuilder = () => {
|
||||
shouldFocus={shouldFocusTabs}
|
||||
menuItems={[
|
||||
<IconButton
|
||||
key="collapse-tabs"
|
||||
icon={<Icons.FallOutlined iconSize="xl" />}
|
||||
label={t('Collapse tab content')}
|
||||
onClick={handleDeleteTopLevelTabs}
|
||||
@@ -559,6 +563,9 @@ const DashboardBuilder = () => {
|
||||
const filterBarWidth = dashboardFiltersOpen
|
||||
? adjustedWidth
|
||||
: CLOSED_FILTER_BAR_WIDTH;
|
||||
if (filterBarWidth !== currentFilterBarWidth) {
|
||||
setCurrentFilterBarWidth(filterBarWidth);
|
||||
}
|
||||
return (
|
||||
<FiltersPanel
|
||||
width={filterBarWidth}
|
||||
@@ -591,23 +598,30 @@ const DashboardBuilder = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const isVerticalFilterBarVisible =
|
||||
showFilterBar && filterBarOrientation === FilterBarOrientation.Vertical;
|
||||
const headerFilterBarWidth = isVerticalFilterBarVisible
|
||||
? currentFilterBarWidth
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<DashboardWrapper>
|
||||
{showFilterBar &&
|
||||
filterBarOrientation === FilterBarOrientation.Vertical && (
|
||||
<>
|
||||
<ResizableSidebar
|
||||
id={`dashboard:${dashboardId}`}
|
||||
enable={dashboardFiltersOpen}
|
||||
minWidth={OPEN_FILTER_BAR_WIDTH}
|
||||
maxWidth={OPEN_FILTER_BAR_MAX_WIDTH}
|
||||
initialWidth={OPEN_FILTER_BAR_WIDTH}
|
||||
>
|
||||
{renderChild}
|
||||
</ResizableSidebar>
|
||||
</>
|
||||
)}
|
||||
<StyledHeader ref={headerRef}>
|
||||
{isVerticalFilterBarVisible && (
|
||||
<ResizableSidebar
|
||||
id={`dashboard:${dashboardId}`}
|
||||
enable={dashboardFiltersOpen}
|
||||
minWidth={OPEN_FILTER_BAR_WIDTH}
|
||||
maxWidth={OPEN_FILTER_BAR_MAX_WIDTH}
|
||||
initialWidth={OPEN_FILTER_BAR_WIDTH}
|
||||
>
|
||||
{renderChild}
|
||||
</ResizableSidebar>
|
||||
)}
|
||||
<StyledHeader
|
||||
data-test="dashboard-header-wrapper"
|
||||
ref={headerRef}
|
||||
filterBarWidth={headerFilterBarWidth}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<Droppable
|
||||
data-test="top-level-tabs"
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { storeWithState } from 'spec/fixtures/mockStore';
|
||||
import mockState from 'spec/fixtures/mockState';
|
||||
import { sliceId } from 'spec/fixtures/mockChartQueries';
|
||||
import { NativeFilterType } from '@superset-ui/core';
|
||||
import { CHART_TYPE } from '../../util/componentTypes';
|
||||
import DashboardContainer from './DashboardContainer';
|
||||
import * as nativeFiltersActions from '../../actions/nativeFilters';
|
||||
|
||||
fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {});
|
||||
fetchMock.put('glob:*/api/v1/dashboard/*/colors*', {});
|
||||
fetchMock.post('glob:*/superset/log/?*', {});
|
||||
|
||||
jest.mock('@visx/responsive', () => ({
|
||||
ParentSize: ({
|
||||
children,
|
||||
}: {
|
||||
children: (props: { width: number }) => JSX.Element;
|
||||
}) => children({ width: 800 }),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/containers/DashboardGrid', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-test="mock-dashboard-grid" />,
|
||||
}));
|
||||
|
||||
function createTestState(overrides = {}) {
|
||||
return {
|
||||
...mockState,
|
||||
dashboardState: {
|
||||
...mockState.dashboardState,
|
||||
sliceIds: [sliceId],
|
||||
},
|
||||
dashboardLayout: {
|
||||
...mockState.dashboardLayout,
|
||||
present: {
|
||||
...mockState.dashboardLayout.present,
|
||||
CHART_ID: {
|
||||
id: 'CHART_ID',
|
||||
type: CHART_TYPE,
|
||||
meta: {
|
||||
chartId: sliceId,
|
||||
width: 4,
|
||||
height: 10,
|
||||
},
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'ROW_ID'],
|
||||
},
|
||||
},
|
||||
},
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'FILTER-1': {
|
||||
id: 'FILTER-1',
|
||||
name: 'Test Filter',
|
||||
filterType: 'filter_select',
|
||||
targets: [
|
||||
{
|
||||
datasetId: 1,
|
||||
column: { name: 'country' },
|
||||
},
|
||||
],
|
||||
defaultDataMask: {
|
||||
filterState: { value: null },
|
||||
},
|
||||
cascadeParentIds: [],
|
||||
scope: {
|
||||
rootPath: ['ROOT_ID'],
|
||||
excluded: [],
|
||||
},
|
||||
controlValues: {},
|
||||
type: NativeFilterType.NativeFilter,
|
||||
},
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(overrideState = {}) {
|
||||
const initialState = createTestState(overrideState);
|
||||
return render(<DashboardContainer />, {
|
||||
useRedux: true,
|
||||
store: storeWithState(initialState),
|
||||
});
|
||||
}
|
||||
|
||||
function setupWithStore(overrideState = {}) {
|
||||
const initialState = createTestState(overrideState);
|
||||
const store = storeWithState(initialState);
|
||||
const renderResult = render(<DashboardContainer />, {
|
||||
useRedux: true,
|
||||
store,
|
||||
});
|
||||
return { store, ...renderResult };
|
||||
}
|
||||
|
||||
let setInScopeStatusMock: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
setInScopeStatusMock = jest.spyOn(
|
||||
nativeFiltersActions,
|
||||
'setInScopeStatusOfFilters',
|
||||
);
|
||||
setInScopeStatusMock.mockReturnValue(jest.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setInScopeStatusMock.mockRestore();
|
||||
});
|
||||
|
||||
test('calculates chartsInScope correctly for filters', async () => {
|
||||
setup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setInScopeStatusMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(setInScopeStatusMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filterId: 'FILTER-1',
|
||||
chartsInScope: [sliceId],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('recalculates chartsInScope when filter non-scope properties change', async () => {
|
||||
const { store } = setupWithStore();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setInScopeStatusMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
setInScopeStatusMock.mockClear();
|
||||
|
||||
// Bug scenario: Editing non-scope properties (e.g., "Sort filter values")
|
||||
// triggers backend save, but response lacks chartsInScope.
|
||||
// The fix ensures useEffect recalculates chartsInScope anyway.
|
||||
const initialState = store.getState();
|
||||
store.dispatch({
|
||||
type: 'SET_NATIVE_FILTERS_CONFIG_COMPLETE',
|
||||
filterChanges: [
|
||||
{
|
||||
...initialState.nativeFilters.filters['FILTER-1'],
|
||||
controlValues: {
|
||||
...initialState.nativeFilters.filters['FILTER-1'].controlValues,
|
||||
sortAscending: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setInScopeStatusMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('handles multiple filters with different scopes', async () => {
|
||||
const baseDashboardLayout = mockState.dashboardLayout.present;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { CHART_ID: _removed, ...cleanLayout } = baseDashboardLayout;
|
||||
|
||||
const stateWithMultipleFilters = {
|
||||
...mockState,
|
||||
dashboardState: {
|
||||
...mockState.dashboardState,
|
||||
sliceIds: [18, 19],
|
||||
},
|
||||
dashboardLayout: {
|
||||
...mockState.dashboardLayout,
|
||||
present: {
|
||||
...cleanLayout,
|
||||
CHART_ID_1: {
|
||||
id: 'CHART_ID_1',
|
||||
type: CHART_TYPE,
|
||||
meta: { chartId: 18, width: 4, height: 10 },
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'ROW_ID'],
|
||||
},
|
||||
CHART_ID_2: {
|
||||
id: 'CHART_ID_2',
|
||||
type: CHART_TYPE,
|
||||
meta: { chartId: 19, width: 4, height: 10 },
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'ROW_ID'],
|
||||
},
|
||||
},
|
||||
},
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'FILTER-1': {
|
||||
id: 'FILTER-1',
|
||||
name: 'Filter 1',
|
||||
filterType: 'filter_select',
|
||||
targets: [{ datasetId: 1, column: { name: 'country' } }],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
controlValues: {},
|
||||
type: NativeFilterType.NativeFilter,
|
||||
},
|
||||
'FILTER-2': {
|
||||
id: 'FILTER-2',
|
||||
name: 'Filter 2',
|
||||
filterType: 'filter_select',
|
||||
targets: [{ datasetId: 1, column: { name: 'region' } }],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [19] },
|
||||
controlValues: {},
|
||||
type: NativeFilterType.NativeFilter,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setup(stateWithMultipleFilters);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setInScopeStatusMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(setInScopeStatusMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filterId: 'FILTER-1',
|
||||
chartsInScope: expect.arrayContaining([18, 19]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
filterId: 'FILTER-2',
|
||||
chartsInScope: [18],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('handles filters with no charts in scope', async () => {
|
||||
const stateWithExcludedFilter = createTestState({
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'FILTER-1': {
|
||||
id: 'FILTER-1',
|
||||
name: 'Excluded Filter',
|
||||
filterType: 'filter_select',
|
||||
targets: [{ datasetId: 1, column: { name: 'country' } }],
|
||||
scope: {
|
||||
rootPath: ['ROOT_ID'],
|
||||
excluded: [sliceId],
|
||||
},
|
||||
controlValues: {},
|
||||
type: NativeFilterType.NativeFilter,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setup(stateWithExcludedFilter);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setInScopeStatusMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(setInScopeStatusMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filterId: 'FILTER-1',
|
||||
chartsInScope: [],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not dispatch when there are no filters', () => {
|
||||
const stateWithoutFilters = createTestState({
|
||||
nativeFilters: {
|
||||
filters: {},
|
||||
},
|
||||
});
|
||||
|
||||
setup(stateWithoutFilters);
|
||||
|
||||
expect(setInScopeStatusMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('calculates tabsInScope for filters with tab-scoped charts', async () => {
|
||||
const baseDashboardLayout = mockState.dashboardLayout.present;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { CHART_ID: _removed, ...cleanLayout } = baseDashboardLayout;
|
||||
|
||||
const stateWithTabs = {
|
||||
...mockState,
|
||||
dashboardState: {
|
||||
...mockState.dashboardState,
|
||||
sliceIds: [20, 21, 22],
|
||||
},
|
||||
dashboardLayout: {
|
||||
...mockState.dashboardLayout,
|
||||
present: {
|
||||
...cleanLayout,
|
||||
ROOT_ID: {
|
||||
id: 'ROOT_ID',
|
||||
type: 'ROOT',
|
||||
children: ['TABS-1'],
|
||||
},
|
||||
'TABS-1': {
|
||||
id: 'TABS-1',
|
||||
type: 'TABS',
|
||||
children: ['TAB-A', 'TAB-B'],
|
||||
parents: ['ROOT_ID'],
|
||||
},
|
||||
'TAB-A': {
|
||||
id: 'TAB-A',
|
||||
type: 'TAB',
|
||||
children: ['CHART-A1', 'CHART-A2'],
|
||||
parents: ['ROOT_ID', 'TABS-1'],
|
||||
meta: { text: 'Tab A' },
|
||||
},
|
||||
'TAB-B': {
|
||||
id: 'TAB-B',
|
||||
type: 'TAB',
|
||||
children: ['CHART-B1'],
|
||||
parents: ['ROOT_ID', 'TABS-1'],
|
||||
meta: { text: 'Tab B' },
|
||||
},
|
||||
'CHART-A1': {
|
||||
id: 'CHART-A1',
|
||||
type: CHART_TYPE,
|
||||
meta: { chartId: 20, width: 4, height: 10 },
|
||||
parents: ['ROOT_ID', 'TABS-1', 'TAB-A'],
|
||||
},
|
||||
'CHART-A2': {
|
||||
id: 'CHART-A2',
|
||||
type: CHART_TYPE,
|
||||
meta: { chartId: 21, width: 4, height: 10 },
|
||||
parents: ['ROOT_ID', 'TABS-1', 'TAB-A'],
|
||||
},
|
||||
'CHART-B1': {
|
||||
id: 'CHART-B1',
|
||||
type: CHART_TYPE,
|
||||
meta: { chartId: 22, width: 4, height: 10 },
|
||||
parents: ['ROOT_ID', 'TABS-1', 'TAB-B'],
|
||||
},
|
||||
},
|
||||
},
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'FILTER-TAB-SCOPED': {
|
||||
id: 'FILTER-TAB-SCOPED',
|
||||
name: 'Tab Scoped Filter',
|
||||
filterType: 'filter_select',
|
||||
targets: [{ datasetId: 1, column: { name: 'region' } }],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [22] },
|
||||
controlValues: {},
|
||||
type: NativeFilterType.NativeFilter,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setup(stateWithTabs);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setInScopeStatusMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(setInScopeStatusMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filterId: 'FILTER-TAB-SCOPED',
|
||||
chartsInScope: expect.arrayContaining([20, 21]),
|
||||
tabsInScope: expect.arrayContaining(['TAB-A']),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -103,6 +103,9 @@ const TOP_OF_PAGE_RANGE = 220;
|
||||
|
||||
const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
|
||||
const nativeFilterScopes = useNativeFilterScopes();
|
||||
const nativeFilters = useSelector<RootState, Filters>(
|
||||
state => state.nativeFilters?.filters,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const dashboardLayout = useSelector<RootState, DashboardLayout>(
|
||||
@@ -192,7 +195,13 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
|
||||
};
|
||||
});
|
||||
dispatch(setInScopeStatusOfFilters(scopes));
|
||||
}, [chartIds, JSON.stringify(nativeFilterScopes), dashboardLayout, dispatch]);
|
||||
}, [
|
||||
chartIds,
|
||||
JSON.stringify(nativeFilterScopes),
|
||||
dashboardLayout,
|
||||
dispatch,
|
||||
JSON.stringify(nativeFilters),
|
||||
]);
|
||||
|
||||
const childIds: string[] = useMemo(
|
||||
() => (topLevelTabs ? topLevelTabs.children : [DASHBOARD_GRID_ID]),
|
||||
|
||||
@@ -616,4 +616,115 @@ describe('PropertiesModal', () => {
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Superset Admin');
|
||||
}, 30000);
|
||||
|
||||
test('should not run validation while data is loading', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(false);
|
||||
const props = createProps();
|
||||
|
||||
// Don't pass dashboardInfo to trigger fetch behavior
|
||||
const { rerender } = render(<PropertiesModal {...props} show={false} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
const getSpy = jest.spyOn(SupersetCore.SupersetClient, 'get');
|
||||
let resolveFetch: any;
|
||||
const fetchPromise = new Promise(resolve => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
|
||||
getSpy.mockReturnValue(fetchPromise as any);
|
||||
|
||||
rerender(<PropertiesModal {...props} show />);
|
||||
|
||||
// Allow time for validation hooks to run (they shouldn't during loading)
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('dashboard-edit-properties-form'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
resolveFetch({
|
||||
json: {
|
||||
result: { ...dashboardInfo, json_metadata: mockedJsonMetadata },
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('dashboard-edit-properties-form'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
});
|
||||
|
||||
getSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should run validation when data is ready', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(false);
|
||||
const props = createProps();
|
||||
|
||||
// Pass dashboardInfo to skip loading state - data is immediately ready
|
||||
const propsWithDashboardInfo = {
|
||||
...props,
|
||||
dashboardInfo: {
|
||||
...dashboardInfo,
|
||||
json_metadata: mockedJsonMetadata,
|
||||
},
|
||||
};
|
||||
|
||||
render(<PropertiesModal {...propsWithDashboardInfo} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('dashboard-edit-properties-form'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('should trigger validation on field changes when data is ready', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(false);
|
||||
const props = createProps();
|
||||
|
||||
const propsWithDashboardInfo = {
|
||||
...props,
|
||||
dashboardInfo: {
|
||||
...dashboardInfo,
|
||||
json_metadata: mockedJsonMetadata,
|
||||
},
|
||||
};
|
||||
|
||||
render(<PropertiesModal {...propsWithDashboardInfo} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('dashboard-edit-properties-form'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const titleInput = screen.getAllByRole('textbox')[0];
|
||||
|
||||
// Clear to trigger validation error
|
||||
userEvent.clear(titleInput);
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
|
||||
// Re-enter valid title to clear error
|
||||
userEvent.type(titleInput, 'New Dashboard Title');
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -586,20 +586,28 @@ const PropertiesModal = ({
|
||||
sections: modalSections,
|
||||
});
|
||||
|
||||
// Validate basic section when title changes
|
||||
useEffect(() => {
|
||||
validateSection('basic');
|
||||
}, [dashboardTitle, validateSection]);
|
||||
const isDataReady = !isLoading && dashboardInfo;
|
||||
|
||||
// Validate advanced section when JSON changes
|
||||
// Validate basic section when title changes or data loads
|
||||
useEffect(() => {
|
||||
validateSection('advanced');
|
||||
}, [jsonMetadata, validateSection]);
|
||||
if (isDataReady) {
|
||||
validateSection('basic');
|
||||
}
|
||||
}, [dashboardTitle, validateSection, isDataReady]);
|
||||
|
||||
// Validate refresh section when refresh frequency changes
|
||||
// Validate advanced section when JSON changes or data loads
|
||||
useEffect(() => {
|
||||
validateSection('refresh');
|
||||
}, [refreshFrequency, validateSection]);
|
||||
if (isDataReady) {
|
||||
validateSection('advanced');
|
||||
}
|
||||
}, [jsonMetadata, validateSection, isDataReady]);
|
||||
|
||||
// Validate refresh section when frequency changes or data loads
|
||||
useEffect(() => {
|
||||
if (isDataReady) {
|
||||
validateSection('refresh');
|
||||
}
|
||||
}, [refreshFrequency, validateSection, isDataReady]);
|
||||
|
||||
return (
|
||||
<StandardModal
|
||||
@@ -630,7 +638,9 @@ const PropertiesModal = ({
|
||||
onFinish={onFinish}
|
||||
onFieldsChange={() => {
|
||||
// Re-validate sections when form fields change
|
||||
setTimeout(() => validateSection('basic'), 100);
|
||||
if (isDataReady) {
|
||||
validateSection('basic');
|
||||
}
|
||||
}}
|
||||
data-test="dashboard-edit-properties-form"
|
||||
layout="vertical"
|
||||
|
||||
@@ -152,7 +152,6 @@ const ActionButtons = ({
|
||||
<Button
|
||||
disabled={!isClearAllEnabled}
|
||||
buttonStyle="link"
|
||||
buttonSize="small"
|
||||
className="filter-clear-all-button"
|
||||
onClick={onClearAll}
|
||||
{...getFilterBarTestId('clear-button')}
|
||||
|
||||
@@ -53,6 +53,7 @@ const VerticalFilterControlTitle = styled.h4`
|
||||
const HorizontalFilterControlTitle = styled(VerticalFilterControlTitle)`
|
||||
font-weight: ${({ theme }) => theme.fontWeightNormal};
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
margin: 0;
|
||||
${truncationCSS};
|
||||
`;
|
||||
|
||||
@@ -60,6 +61,7 @@ const HorizontalOverflowFilterControlTitle = styled(
|
||||
HorizontalFilterControlTitle,
|
||||
)`
|
||||
max-width: none;
|
||||
margin: ${({ theme }) => `${theme.sizeUnit * 2}px 0 ${theme.sizeUnit}px`};
|
||||
`;
|
||||
|
||||
const VerticalFilterControlTitleBox = styled.div`
|
||||
@@ -162,7 +164,7 @@ const HorizontalFormItem = styled(StyledFormItem)<{
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-form-item-label {
|
||||
&& > .ant-row > .ant-form-item-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
|
||||
@@ -62,7 +62,15 @@ import { useFilterOutlined } from '../useFilterOutlined';
|
||||
const HEIGHT = 32;
|
||||
|
||||
// Overrides superset-ui height with min-height
|
||||
const StyledDiv = styled.div`
|
||||
const StyledDiv = styled.div<{
|
||||
orientation: FilterBarOrientation;
|
||||
overflow: boolean;
|
||||
}>`
|
||||
padding-bottom: ${({ theme, orientation, overflow }) =>
|
||||
orientation === FilterBarOrientation.Horizontal && !overflow
|
||||
? 0
|
||||
: (theme?.sizeUnit ?? 4)}px;
|
||||
|
||||
& > div {
|
||||
height: auto !important;
|
||||
min-height: ${HEIGHT}px;
|
||||
@@ -353,7 +361,11 @@ const FilterValue: FC<FilterControlProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledDiv data-test="form-item-value">
|
||||
<StyledDiv
|
||||
data-test="form-item-value"
|
||||
orientation={orientation}
|
||||
overflow={overflow}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loading position="inline-centered" size="s" muted />
|
||||
) : (
|
||||
|
||||
@@ -184,9 +184,6 @@ afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Set timeout for all tests in this file to prevent CI timeouts
|
||||
jest.setTimeout(60000);
|
||||
|
||||
function defaultRender(initialState: any = defaultState(), modalProps = props) {
|
||||
return render(<FiltersConfigModal {...modalProps} />, {
|
||||
initialState,
|
||||
@@ -226,9 +223,10 @@ test('renders a value filter type', () => {
|
||||
test('renders a numerical range filter type', async () => {
|
||||
defaultRender();
|
||||
|
||||
userEvent.click(screen.getByText(VALUE_REGEX));
|
||||
await userEvent.click(screen.getByText(VALUE_REGEX));
|
||||
|
||||
await waitFor(() => userEvent.click(screen.getByText(NUMERICAL_RANGE_REGEX)));
|
||||
const numericalRangeOption = await screen.findByText(NUMERICAL_RANGE_REGEX);
|
||||
await userEvent.click(numericalRangeOption);
|
||||
|
||||
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
|
||||
@@ -250,9 +248,10 @@ test('renders a numerical range filter type', async () => {
|
||||
test('renders a time range filter type', async () => {
|
||||
defaultRender();
|
||||
|
||||
userEvent.click(screen.getByText(VALUE_REGEX));
|
||||
await userEvent.click(screen.getByText(VALUE_REGEX));
|
||||
|
||||
await waitFor(() => userEvent.click(screen.getByText(TIME_RANGE_REGEX)));
|
||||
const timeRangeOption = await screen.findByText(TIME_RANGE_REGEX);
|
||||
await userEvent.click(timeRangeOption);
|
||||
|
||||
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
|
||||
@@ -265,9 +264,10 @@ test('renders a time range filter type', async () => {
|
||||
test('renders a time column filter type', async () => {
|
||||
defaultRender();
|
||||
|
||||
userEvent.click(screen.getByText(VALUE_REGEX));
|
||||
await userEvent.click(screen.getByText(VALUE_REGEX));
|
||||
|
||||
await waitFor(() => userEvent.click(screen.getByText(TIME_COLUMN_REGEX)));
|
||||
const timeColumnOption = await screen.findByText(TIME_COLUMN_REGEX);
|
||||
await userEvent.click(timeColumnOption);
|
||||
|
||||
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
|
||||
@@ -280,9 +280,10 @@ test('renders a time column filter type', async () => {
|
||||
test('renders a time grain filter type', async () => {
|
||||
defaultRender();
|
||||
|
||||
userEvent.click(screen.getByText(VALUE_REGEX));
|
||||
await userEvent.click(screen.getByText(VALUE_REGEX));
|
||||
|
||||
await waitFor(() => userEvent.click(screen.getByText(TIME_GRAIN_REGEX)));
|
||||
const timeGrainOption = await screen.findByText(TIME_GRAIN_REGEX);
|
||||
await userEvent.click(timeGrainOption);
|
||||
|
||||
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
|
||||
@@ -295,7 +296,7 @@ test('renders a time grain filter type', async () => {
|
||||
test('render time filter types as disabled if there are no temporal columns in the dataset', async () => {
|
||||
defaultRender(noTemporalColumnsState());
|
||||
|
||||
userEvent.click(screen.getByText(VALUE_REGEX));
|
||||
await userEvent.click(screen.getByText(VALUE_REGEX));
|
||||
|
||||
const timeRange = await screen.findByText(TIME_RANGE_REGEX);
|
||||
const timeGrain = await screen.findByText(TIME_GRAIN_REGEX);
|
||||
@@ -309,7 +310,7 @@ test('render time filter types as disabled if there are no temporal columns in t
|
||||
|
||||
test('validates the name', async () => {
|
||||
defaultRender();
|
||||
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
await waitFor(
|
||||
async () => {
|
||||
expect(await screen.findByText(NAME_REQUIRED_REGEX)).toBeInTheDocument();
|
||||
@@ -320,7 +321,7 @@ test('validates the name', async () => {
|
||||
|
||||
test('validates the column', async () => {
|
||||
defaultRender();
|
||||
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
expect(await screen.findByText(COLUMN_REQUIRED_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -328,8 +329,8 @@ test('validates the column', async () => {
|
||||
test.skip('validates the default value', async () => {
|
||||
defaultRender(noTemporalColumnsState());
|
||||
expect(await screen.findByText('birth_names')).toBeInTheDocument();
|
||||
userEvent.type(screen.getByRole('combobox'), `Column A{Enter}`);
|
||||
userEvent.click(getCheckbox(DEFAULT_VALUE_REGEX));
|
||||
await userEvent.type(screen.getByRole('combobox'), `Column A{Enter}`);
|
||||
await userEvent.click(getCheckbox(DEFAULT_VALUE_REGEX));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(FILL_REQUIRED_FIELDS_REGEX),
|
||||
@@ -345,21 +346,24 @@ test('validates the pre-filter value', async () => {
|
||||
try {
|
||||
defaultRender();
|
||||
|
||||
userEvent.click(screen.getByText(FILTER_SETTINGS_REGEX));
|
||||
userEvent.click(getCheckbox(PRE_FILTER_REGEX));
|
||||
await userEvent.click(screen.getByText(FILTER_SETTINGS_REGEX));
|
||||
await userEvent.click(getCheckbox(PRE_FILTER_REGEX));
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessages = screen.getAllByText(PRE_FILTER_REQUIRED_REGEX);
|
||||
expect(errorMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
|
||||
// Wait for validation to complete after timer switch
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText(PRE_FILTER_REQUIRED_REGEX)).toBeInTheDocument();
|
||||
const errorMessages = screen.queryAllByText(PRE_FILTER_REQUIRED_REGEX);
|
||||
expect(errorMessages.length).toBeGreaterThan(0);
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
@@ -368,13 +372,13 @@ test('validates the pre-filter value', async () => {
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
test.skip("doesn't render time range pre-filter if there are no temporal columns in datasource", async () => {
|
||||
defaultRender(noTemporalColumnsState());
|
||||
userEvent.click(screen.getByText(DATASET_REGEX));
|
||||
await waitFor(() => {
|
||||
await userEvent.click(screen.getByText(DATASET_REGEX));
|
||||
await waitFor(async () => {
|
||||
expect(screen.queryByLabelText('Loading')).not.toBeInTheDocument();
|
||||
userEvent.click(screen.getByText('birth_names'));
|
||||
await userEvent.click(screen.getByText('birth_names'));
|
||||
});
|
||||
userEvent.click(screen.getByText(FILTER_SETTINGS_REGEX));
|
||||
userEvent.click(getCheckbox(PRE_FILTER_REGEX));
|
||||
await userEvent.click(screen.getByText(FILTER_SETTINGS_REGEX));
|
||||
await userEvent.click(getCheckbox(PRE_FILTER_REGEX));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText(TIME_RANGE_PREFILTER_REGEX),
|
||||
@@ -439,9 +443,9 @@ test('deletes a filter', async () => {
|
||||
const removeButtons = screen.getAllByRole('button', {
|
||||
name: 'delete',
|
||||
});
|
||||
userEvent.click(removeButtons[2]);
|
||||
await userEvent.click(removeButtons[2]);
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
@@ -476,8 +480,8 @@ test('deletes a filter including dependencies', async () => {
|
||||
const removeButtons = screen.getAllByRole('button', {
|
||||
name: 'delete',
|
||||
});
|
||||
userEvent.click(removeButtons[1]);
|
||||
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
await userEvent.click(removeButtons[1]);
|
||||
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -525,7 +529,7 @@ test('switches the order between two filters', async () => {
|
||||
|
||||
fireEvent.dragEnd(draggableFilters[0]);
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
@@ -568,14 +572,14 @@ test('rearranges three filters and deletes one of them', async () => {
|
||||
const deleteButtons = screen.getAllByRole('button', {
|
||||
name: 'delete',
|
||||
});
|
||||
userEvent.click(deleteButtons[1]);
|
||||
await userEvent.click(deleteButtons[1]);
|
||||
|
||||
fireEvent.dragStart(draggableFilters[0]);
|
||||
fireEvent.dragOver(draggableFilters[2]);
|
||||
fireEvent.drop(draggableFilters[2]);
|
||||
fireEvent.dragEnd(draggableFilters[0]);
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
@@ -594,47 +598,51 @@ test('rearranges three filters and deletes one of them', async () => {
|
||||
|
||||
test('modifies the name of a filter', async () => {
|
||||
jest.useFakeTimers();
|
||||
const nativeFilterState = [
|
||||
buildNativeFilter('NATIVE_FILTER-1', 'state', []),
|
||||
buildNativeFilter('NATIVE_FILTER-2', 'country', []),
|
||||
];
|
||||
try {
|
||||
const nativeFilterState = [
|
||||
buildNativeFilter('NATIVE_FILTER-1', 'state', []),
|
||||
buildNativeFilter('NATIVE_FILTER-2', 'country', []),
|
||||
];
|
||||
|
||||
const state = {
|
||||
...defaultState(),
|
||||
dashboardInfo: {
|
||||
metadata: { native_filter_configuration: nativeFilterState },
|
||||
},
|
||||
dashboardLayout,
|
||||
};
|
||||
const state = {
|
||||
...defaultState(),
|
||||
dashboardInfo: {
|
||||
metadata: { native_filter_configuration: nativeFilterState },
|
||||
},
|
||||
dashboardLayout,
|
||||
};
|
||||
|
||||
const onSave = jest.fn();
|
||||
const onSave = jest.fn();
|
||||
|
||||
defaultRender(state, {
|
||||
...props,
|
||||
createNewOnOpen: false,
|
||||
onSave,
|
||||
});
|
||||
defaultRender(state, {
|
||||
...props,
|
||||
createNewOnOpen: false,
|
||||
onSave,
|
||||
});
|
||||
|
||||
const filterNameInput = screen.getByRole('textbox', {
|
||||
name: FILTER_NAME_REGEX,
|
||||
});
|
||||
const filterNameInput = screen.getByRole('textbox', {
|
||||
name: FILTER_NAME_REGEX,
|
||||
});
|
||||
|
||||
userEvent.clear(filterNameInput);
|
||||
userEvent.type(filterNameInput, 'New Filter Name');
|
||||
await userEvent.clear(filterNameInput);
|
||||
await userEvent.type(filterNameInput, 'New Filter Name');
|
||||
|
||||
jest.runAllTimers();
|
||||
jest.runAllTimers();
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modified: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'New Filter Name' }),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modified: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'New Filter Name' }),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('renders a filter with a chart containing BigInt values', async () => {
|
||||
|
||||
@@ -39,90 +39,260 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('useIsFilterInScope', () => {
|
||||
test('should return true for dividers (always in scope)', () => {
|
||||
const divider: Divider = {
|
||||
id: 'divider_1',
|
||||
type: NativeFilterType.Divider,
|
||||
title: 'Sample Divider',
|
||||
description: 'Divider description',
|
||||
};
|
||||
test('useIsFilterInScope should return true for dividers (always in scope)', () => {
|
||||
const divider: Divider = {
|
||||
id: 'divider_1',
|
||||
type: NativeFilterType.Divider,
|
||||
title: 'Sample Divider',
|
||||
description: 'Divider description',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(divider)).toBe(true);
|
||||
});
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(divider)).toBe(true);
|
||||
});
|
||||
|
||||
test('should return true for filters with charts in active tabs', () => {
|
||||
const filter: Filter = {
|
||||
test('useIsFilterInScope should return true for filters with charts in active tabs', () => {
|
||||
const filter: Filter = {
|
||||
id: 'filter_1',
|
||||
name: 'Test Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [123],
|
||||
scope: { rootPath: ['TAB_1'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
|
||||
description: 'Sample filter description',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(filter)).toBe(true);
|
||||
});
|
||||
|
||||
test('useIsFilterInScope should return false for filters with inactive rootPath', () => {
|
||||
const filter: Filter = {
|
||||
id: 'filter_3',
|
||||
name: 'Test Filter 3',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
scope: { rootPath: ['TAB_99'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 3 }],
|
||||
description: 'Sample filter description',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(filter)).toBe(false);
|
||||
});
|
||||
|
||||
test('useSelectFiltersInScope should return all filters in scope when no tabs exist', () => {
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
id: 'filter_1',
|
||||
name: 'Test Filter',
|
||||
name: 'Filter 1',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [123],
|
||||
scope: { rootPath: ['TAB_1'], excluded: [] },
|
||||
scope: { rootPath: [], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
|
||||
description: 'Sample filter description',
|
||||
};
|
||||
},
|
||||
{
|
||||
id: 'filter_2',
|
||||
name: 'Filter 2',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
scope: { rootPath: [], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 2 }],
|
||||
description: 'Sample filter description',
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(filter)).toBe(true);
|
||||
const { result } = renderHook(() => useSelectFiltersInScope(filters));
|
||||
expect(result.current[0]).toEqual(filters);
|
||||
expect(result.current[1]).toEqual([]);
|
||||
});
|
||||
|
||||
// Tests for filter scope persistence when chartsInScope is missing
|
||||
// (Bug fix: filters incorrectly marked out of scope after editing non-scope properties)
|
||||
test('filter without chartsInScope should fall back to rootPath check', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: ['TAB_1'] },
|
||||
dashboardLayout: { present: {} },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
test('should return false for filters with inactive rootPath', () => {
|
||||
const filter: Filter = {
|
||||
id: 'filter_3',
|
||||
name: 'Test Filter 3',
|
||||
const filter: Filter = {
|
||||
id: 'filter_fallback',
|
||||
name: 'Filter Without Charts',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
scope: { rootPath: ['TAB_1'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
|
||||
description: 'Filter with missing chartsInScope',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(filter)).toBe(true);
|
||||
});
|
||||
|
||||
test('filter with empty chartsInScope array should check rootPath', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: ['TAB_1'] },
|
||||
dashboardLayout: { present: {} },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filter: Filter = {
|
||||
id: 'filter_empty_charts',
|
||||
name: 'Filter With Empty Charts',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [],
|
||||
scope: { rootPath: ['TAB_1'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
|
||||
description: 'Filter with empty chartsInScope',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(filter)).toBe(true);
|
||||
});
|
||||
|
||||
test('filter without chartsInScope and inactive rootPath should be out of scope', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: ['TAB_1'] },
|
||||
dashboardLayout: { present: {} },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filter: Filter = {
|
||||
id: 'filter_inactive',
|
||||
name: 'Inactive Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
scope: { rootPath: ['INACTIVE_TAB'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
|
||||
description: 'Filter in inactive tab',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(filter)).toBe(false);
|
||||
});
|
||||
|
||||
test('filter with ROOT_ID in rootPath should be in scope when chartsInScope is missing', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: ['ROOT_ID'] },
|
||||
dashboardLayout: { present: {} },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filter: Filter = {
|
||||
id: 'filter_root',
|
||||
name: 'Global Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
|
||||
description: 'Global filter with missing chartsInScope',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(filter)).toBe(true);
|
||||
});
|
||||
|
||||
test('useSelectFiltersInScope correctly categorizes filters with missing chartsInScope', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: ['TAB_1'] },
|
||||
dashboardLayout: {
|
||||
present: {
|
||||
tab1: { type: 'TAB', id: 'TAB_1' },
|
||||
},
|
||||
},
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
id: 'filter_in_scope',
|
||||
name: 'In Scope Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
scope: { rootPath: ['TAB_1'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
|
||||
description: 'Filter that should be in scope',
|
||||
},
|
||||
{
|
||||
id: 'filter_out_scope',
|
||||
name: 'Out of Scope Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
scope: { rootPath: ['TAB_99'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 3 }],
|
||||
description: 'Sample filter description',
|
||||
};
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 2 }],
|
||||
description: 'Filter that should be out of scope',
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(filter)).toBe(false);
|
||||
});
|
||||
const { result } = renderHook(() => useSelectFiltersInScope(filters));
|
||||
|
||||
expect(result.current[0]).toContainEqual(
|
||||
expect.objectContaining({ id: 'filter_in_scope' }),
|
||||
);
|
||||
expect(result.current[1]).toContainEqual(
|
||||
expect.objectContaining({ id: 'filter_out_scope' }),
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('useSelectFiltersInScope', () => {
|
||||
test('should return all filters in scope when no tabs exist', () => {
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
id: 'filter_1',
|
||||
name: 'Filter 1',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
scope: { rootPath: [], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
|
||||
description: 'Sample filter description',
|
||||
},
|
||||
{
|
||||
id: 'filter_2',
|
||||
name: 'Filter 2',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
scope: { rootPath: [], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 2 }],
|
||||
description: 'Sample filter description',
|
||||
},
|
||||
];
|
||||
test('filter with chartsInScope takes precedence over rootPath', () => {
|
||||
const filter: Filter = {
|
||||
id: 'filter_priority',
|
||||
name: 'Priority Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [123, 456],
|
||||
scope: { rootPath: ['INACTIVE_TAB'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
|
||||
description: 'Filter with chartsInScope should ignore rootPath',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useSelectFiltersInScope(filters));
|
||||
expect(result.current[0]).toEqual(filters);
|
||||
expect(result.current[1]).toEqual([]);
|
||||
});
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(filter)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -153,7 +153,7 @@ export const useDownloadScreenshot = (
|
||||
anchor,
|
||||
activeTabs,
|
||||
dataMask,
|
||||
urlParams: getDashboardUrlParams(['edit']),
|
||||
urlParams: getDashboardUrlParams(),
|
||||
},
|
||||
})
|
||||
.then(({ json }) => {
|
||||
|
||||
162
superset-frontend/src/dashboard/reducers/nativeFilters.test.ts
Normal file
162
superset-frontend/src/dashboard/reducers/nativeFilters.test.ts
Normal 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([]);
|
||||
});
|
||||
@@ -69,7 +69,16 @@ function handleFilterChangesComplete(
|
||||
) {
|
||||
const modifiedFilters = { ...state.filters };
|
||||
filters.forEach(filter => {
|
||||
modifiedFilters[filter.id] = filter;
|
||||
if (filter.chartsInScope != null && filter.tabsInScope != null) {
|
||||
modifiedFilters[filter.id] = filter;
|
||||
} else {
|
||||
const existingFilter = modifiedFilters[filter.id];
|
||||
modifiedFilters[filter.id] = {
|
||||
...filter,
|
||||
chartsInScope: filter.chartsInScope ?? existingFilter?.chartsInScope,
|
||||
tabsInScope: filter.tabsInScope ?? existingFilter?.tabsInScope,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -153,12 +153,6 @@ test('Should render null when show:false', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Add cleanup after each test
|
||||
afterEach(async () => {
|
||||
// Wait for any pending effects to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
test('Should render when show:true', async () => {
|
||||
const props = createProps();
|
||||
renderModal(props);
|
||||
@@ -193,7 +187,7 @@ test('"Close" button should call "onHide"', async () => {
|
||||
expect(props.onHide).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onHide).toHaveBeenCalledTimes(1);
|
||||
@@ -254,7 +248,7 @@ test('"Cancel" button should call "onHide"', async () => {
|
||||
expect(props.onHide).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onHide).toHaveBeenCalledTimes(1);
|
||||
@@ -262,7 +256,7 @@ test('"Cancel" button should call "onHide"', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('"Save" button should call only "onSave"', async () => {
|
||||
test('"Save" button should call "onSave" and "onHide"', async () => {
|
||||
const props = createProps();
|
||||
renderModal(props);
|
||||
await waitFor(() => {
|
||||
@@ -271,7 +265,7 @@ test('"Save" button should call only "onSave"', async () => {
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
});
|
||||
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledTimes(1);
|
||||
@@ -293,7 +287,7 @@ test('Empty "Certified by" should clear "Certification details"', async () => {
|
||||
// Expand the Advanced section first to access certification details
|
||||
const advancedPanel = screen.getByText('Advanced').closest('[role="tab"]');
|
||||
if (advancedPanel) {
|
||||
userEvent.click(advancedPanel);
|
||||
await userEvent.click(advancedPanel);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -312,11 +306,11 @@ test('"Name" should not be empty', async () => {
|
||||
|
||||
const name = screen.getByRole('textbox', { name: 'Name' });
|
||||
|
||||
userEvent.clear(name);
|
||||
await userEvent.clear(name);
|
||||
|
||||
expect(name).toHaveValue('');
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledTimes(0);
|
||||
@@ -329,12 +323,12 @@ test('"Name" should not be empty when saved', async () => {
|
||||
|
||||
const name = screen.getByRole('textbox', { name: 'Name' });
|
||||
|
||||
userEvent.clear(name);
|
||||
userEvent.type(name, 'Test chart new name');
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, 'Test chart new name');
|
||||
|
||||
expect(name).toHaveValue('Test chart new name');
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledTimes(1);
|
||||
@@ -351,19 +345,17 @@ test('"Cache timeout" should not be empty when saved', async () => {
|
||||
// Expand the Configuration section first to access cache timeout
|
||||
const configPanel = screen.getByText('Configuration').closest('[role="tab"]');
|
||||
if (configPanel) {
|
||||
userEvent.click(configPanel);
|
||||
await userEvent.click(configPanel);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
const cacheTimeout = screen.getByRole('textbox', { name: 'Cache timeout' });
|
||||
|
||||
userEvent.clear(cacheTimeout);
|
||||
userEvent.type(cacheTimeout, '1000');
|
||||
|
||||
expect(cacheTimeout).toHaveValue('1000');
|
||||
const cacheTimeout = await screen.findByRole('textbox', {
|
||||
name: 'Cache timeout',
|
||||
});
|
||||
await userEvent.clear(cacheTimeout);
|
||||
await userEvent.type(cacheTimeout, '1000');
|
||||
expect(cacheTimeout).toHaveValue('1000');
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledTimes(1);
|
||||
@@ -386,12 +378,12 @@ test('"Description" should not be empty when saved', async () => {
|
||||
const textboxes = screen.getAllByRole('textbox');
|
||||
const description = textboxes[1]; // Description is the textarea
|
||||
|
||||
userEvent.clear(description);
|
||||
userEvent.type(description, 'Test description');
|
||||
await userEvent.clear(description);
|
||||
await userEvent.type(description, 'Test description');
|
||||
|
||||
expect(description).toHaveValue('Test description');
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledTimes(1);
|
||||
@@ -408,19 +400,17 @@ test('"Certified by" should not be empty when saved', async () => {
|
||||
// Expand the Advanced section first to access certified by
|
||||
const advancedPanel = screen.getByText('Advanced').closest('[role="tab"]');
|
||||
if (advancedPanel) {
|
||||
userEvent.click(advancedPanel);
|
||||
await userEvent.click(advancedPanel);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
const certifiedBy = screen.getByRole('textbox', { name: 'Certified by' });
|
||||
|
||||
userEvent.clear(certifiedBy);
|
||||
userEvent.type(certifiedBy, 'Test certified by');
|
||||
|
||||
expect(certifiedBy).toHaveValue('Test certified by');
|
||||
const certifiedBy = await screen.findByRole('textbox', {
|
||||
name: 'Certified by',
|
||||
});
|
||||
await userEvent.clear(certifiedBy);
|
||||
await userEvent.type(certifiedBy, 'Test certified by');
|
||||
expect(certifiedBy).toHaveValue('Test certified by');
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledTimes(1);
|
||||
@@ -437,21 +427,17 @@ test('"Certification details" should not be empty when saved', async () => {
|
||||
// Expand the Advanced section first to access certification details
|
||||
const advancedPanel = screen.getByText('Advanced').closest('[role="tab"]');
|
||||
if (advancedPanel) {
|
||||
userEvent.click(advancedPanel);
|
||||
await userEvent.click(advancedPanel);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
const certificationDetails = screen.getByRole('textbox', {
|
||||
name: 'Certification details',
|
||||
});
|
||||
|
||||
userEvent.clear(certificationDetails);
|
||||
userEvent.type(certificationDetails, 'Test certification details');
|
||||
|
||||
expect(certificationDetails).toHaveValue('Test certification details');
|
||||
const certificationDetails = await screen.findByRole('textbox', {
|
||||
name: 'Certification details',
|
||||
});
|
||||
await userEvent.clear(certificationDetails);
|
||||
await userEvent.type(certificationDetails, 'Test certification details');
|
||||
expect(certificationDetails).toHaveValue('Test certification details');
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledTimes(1);
|
||||
@@ -474,18 +460,14 @@ test('Should display only custom tags when tagging system is enabled', async ()
|
||||
const props = createProps();
|
||||
renderModal(props);
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText('Tags')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole('combobox', { name: 'Tags' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(await screen.findByText('Tags')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole('combobox', { name: 'Tags' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText('my test tag')).toBeInTheDocument();
|
||||
expect(screen.queryByText('type:chart')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('owner:1')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(await screen.findByText('my test tag')).toBeInTheDocument();
|
||||
expect(screen.queryByText('type:chart')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('owner:1')).not.toBeInTheDocument();
|
||||
|
||||
mockIsFeatureEnabled.mockRestore();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { screen, render, waitFor } from 'spec/helpers/testing-library';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import ViewQueryModal from './ViewQueryModal';
|
||||
|
||||
const mockFormData = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'table',
|
||||
};
|
||||
|
||||
const chartDataEndpoint = 'glob:*/api/v1/chart/data*';
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
test('renders Alert component when query result contains validation error', async () => {
|
||||
/**
|
||||
* Regression test for issue #35492 - Phase 1
|
||||
* Verifies that validation errors from the backend are displayed in an Alert
|
||||
* component instead of showing a blank panel
|
||||
*/
|
||||
// Mock API response with validation error
|
||||
fetchMock.post(chartDataEndpoint, {
|
||||
result: [
|
||||
{
|
||||
error: 'Missing temporal column',
|
||||
language: 'sql',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ViewQueryModal latestQueryFormData={mockFormData} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Wait for API call to complete
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.calls(chartDataEndpoint)).toHaveLength(1),
|
||||
);
|
||||
|
||||
// Assert Alert component is rendered with error message
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Missing temporal column')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders both Alert and SQL query when parsing error occurs', async () => {
|
||||
/**
|
||||
* Regression test for issue #35492 - Phase 2
|
||||
* Verifies that parsing errors (which occur after SQL generation) display
|
||||
* both the error message AND the SQL query that failed to parse.
|
||||
*
|
||||
* This differs from validation errors (Phase 1) where no SQL was generated.
|
||||
* For parsing errors, the SQL was successfully compiled but optimization failed.
|
||||
*/
|
||||
// Mock API response with parsing error (has both query and error)
|
||||
fetchMock.post(chartDataEndpoint, {
|
||||
result: [
|
||||
{
|
||||
query: 'SELECT SUM ( Open',
|
||||
error: "Error parsing near 'Open' at line 1:17",
|
||||
language: 'sql',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ViewQueryModal latestQueryFormData={mockFormData} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Wait for the error message to appear
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("Error parsing near 'Open' at line 1:17"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Assert Alert component is rendered with error message
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
|
||||
// Assert SQL query is also displayed
|
||||
// Note: The SQL is rendered inside a syntax-highlighted code block where
|
||||
// each keyword is in a separate span element
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SELECT')).toBeInTheDocument();
|
||||
expect(screen.getByText('SUM')).toBeInTheDocument();
|
||||
expect(screen.getByText('Open')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FC, Fragment, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
ensureIsArray,
|
||||
@@ -24,8 +24,9 @@ import {
|
||||
getClientErrorObject,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/ui';
|
||||
import { styled, Alert } from '@apache-superset/core/ui';
|
||||
import { Loading } from '@superset-ui/core/components';
|
||||
import { SupportedLanguage } from '@superset-ui/core/components/CodeSyntaxHighlighter';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
import ViewQuery from 'src/explore/components/controls/ViewQuery';
|
||||
|
||||
@@ -34,8 +35,9 @@ interface Props {
|
||||
}
|
||||
|
||||
type Result = {
|
||||
query: string;
|
||||
language: string;
|
||||
query?: string;
|
||||
language: SupportedLanguage;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const ViewQueryModalContainer = styled.div`
|
||||
@@ -87,16 +89,21 @@ const ViewQueryModal: FC<Props> = ({ latestQueryFormData }) => {
|
||||
|
||||
return (
|
||||
<ViewQueryModalContainer>
|
||||
{result.map((item, index) =>
|
||||
item.query ? (
|
||||
<ViewQuery
|
||||
key={`query-${index}`}
|
||||
datasource={latestQueryFormData.datasource}
|
||||
sql={item.query}
|
||||
language="sql"
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
{result.map((item, index) => (
|
||||
// Static API response data - index is appropriate for keys
|
||||
<Fragment key={index}>
|
||||
{item.error && (
|
||||
<Alert type="error" message={item.error} closable={false} />
|
||||
)}
|
||||
{item.query && (
|
||||
<ViewQuery
|
||||
datasource={latestQueryFormData.datasource}
|
||||
sql={item.query}
|
||||
language={item.language}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</ViewQueryModalContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -379,4 +379,300 @@ describe('SelectFilterPlugin', () => {
|
||||
userEvent.type(screen.getByRole('combobox'), 'new value');
|
||||
expect(await screen.findByTitle('new value')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('preserves backend order when sortMetric is specified', () => {
|
||||
const testData = [
|
||||
{ gender: 'zebra' },
|
||||
{ gender: 'alpha' },
|
||||
{ gender: 'beta' },
|
||||
];
|
||||
|
||||
const testProps = {
|
||||
...selectMultipleProps,
|
||||
formData: {
|
||||
...selectMultipleProps.formData,
|
||||
sortMetric: 'count',
|
||||
sortAscending: true,
|
||||
},
|
||||
queriesData: [
|
||||
{
|
||||
rowcount: 3,
|
||||
colnames: ['gender'],
|
||||
coltypes: [1],
|
||||
data: testData,
|
||||
applied_filters: [{ column: 'gender' }],
|
||||
rejected_filters: [],
|
||||
},
|
||||
],
|
||||
filterState: {
|
||||
value: [],
|
||||
label: '',
|
||||
excludeFilterValues: true,
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
// @ts-ignore
|
||||
<SelectFilterPlugin
|
||||
// @ts-ignore
|
||||
{...transformProps(testProps)}
|
||||
setDataMask={jest.fn()}
|
||||
showOverflow={false}
|
||||
/>,
|
||||
{
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'test-filter': {
|
||||
name: 'Test Filter',
|
||||
},
|
||||
},
|
||||
},
|
||||
dataMask: {
|
||||
'test-filter': {
|
||||
extraFormData: {},
|
||||
filterState: {
|
||||
value: [],
|
||||
label: '',
|
||||
excludeFilterValues: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const filterSelect = screen.getAllByRole('combobox')[0];
|
||||
userEvent.click(filterSelect);
|
||||
|
||||
// When sortMetric is specified, options should appear in the original data order
|
||||
// (zebra, alpha, beta) not alphabetically sorted
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options[0]).toHaveTextContent('zebra');
|
||||
expect(options[1]).toHaveTextContent('alpha');
|
||||
expect(options[2]).toHaveTextContent('beta');
|
||||
});
|
||||
|
||||
test('applies alphabetical sorting when sortMetric is not specified', () => {
|
||||
const testData = [
|
||||
{ gender: 'zebra' },
|
||||
{ gender: 'alpha' },
|
||||
{ gender: 'beta' },
|
||||
];
|
||||
|
||||
const testProps = {
|
||||
...selectMultipleProps,
|
||||
formData: {
|
||||
...selectMultipleProps.formData,
|
||||
sortMetric: undefined,
|
||||
sortAscending: true,
|
||||
},
|
||||
queriesData: [
|
||||
{
|
||||
rowcount: 3,
|
||||
colnames: ['gender'],
|
||||
coltypes: [1],
|
||||
data: testData,
|
||||
applied_filters: [{ column: 'gender' }],
|
||||
rejected_filters: [],
|
||||
},
|
||||
],
|
||||
filterState: {
|
||||
value: [],
|
||||
label: '',
|
||||
excludeFilterValues: true,
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
// @ts-ignore
|
||||
<SelectFilterPlugin
|
||||
// @ts-ignore
|
||||
{...transformProps(testProps)}
|
||||
setDataMask={jest.fn()}
|
||||
showOverflow={false}
|
||||
/>,
|
||||
{
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'test-filter': {
|
||||
name: 'Test Filter',
|
||||
},
|
||||
},
|
||||
},
|
||||
dataMask: {
|
||||
'test-filter': {
|
||||
extraFormData: {},
|
||||
filterState: {
|
||||
value: [],
|
||||
label: '',
|
||||
excludeFilterValues: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const filterSelect = screen.getAllByRole('combobox')[0];
|
||||
userEvent.click(filterSelect);
|
||||
|
||||
// When sortMetric is not specified, options should be sorted alphabetically
|
||||
// (alpha, beta, zebra)
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options[0]).toHaveTextContent('alpha');
|
||||
expect(options[1]).toHaveTextContent('beta');
|
||||
expect(options[2]).toHaveTextContent('zebra');
|
||||
});
|
||||
|
||||
test('applies descending alphabetical sorting when sortAscending is false and no sortMetric', () => {
|
||||
const testData = [
|
||||
{ gender: 'zebra' },
|
||||
{ gender: 'alpha' },
|
||||
{ gender: 'beta' },
|
||||
];
|
||||
|
||||
const testProps = {
|
||||
...selectMultipleProps,
|
||||
formData: {
|
||||
...selectMultipleProps.formData,
|
||||
sortMetric: undefined,
|
||||
sortAscending: false,
|
||||
},
|
||||
queriesData: [
|
||||
{
|
||||
rowcount: 3,
|
||||
colnames: ['gender'],
|
||||
coltypes: [1],
|
||||
data: testData,
|
||||
applied_filters: [{ column: 'gender' }],
|
||||
rejected_filters: [],
|
||||
},
|
||||
],
|
||||
filterState: {
|
||||
value: [],
|
||||
label: '',
|
||||
excludeFilterValues: true,
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
// @ts-ignore
|
||||
<SelectFilterPlugin
|
||||
// @ts-ignore
|
||||
{...transformProps(testProps)}
|
||||
setDataMask={jest.fn()}
|
||||
showOverflow={false}
|
||||
/>,
|
||||
{
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'test-filter': {
|
||||
name: 'Test Filter',
|
||||
},
|
||||
},
|
||||
},
|
||||
dataMask: {
|
||||
'test-filter': {
|
||||
extraFormData: {},
|
||||
filterState: {
|
||||
value: [],
|
||||
label: '',
|
||||
excludeFilterValues: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const filterSelect = screen.getAllByRole('combobox')[0];
|
||||
userEvent.click(filterSelect);
|
||||
|
||||
// When sortAscending is false and no sortMetric, options should be sorted
|
||||
// in descending alphabetical order (zebra, beta, alpha)
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options[0]).toHaveTextContent('zebra');
|
||||
expect(options[1]).toHaveTextContent('beta');
|
||||
expect(options[2]).toHaveTextContent('alpha');
|
||||
});
|
||||
|
||||
test('preserves backend order even when sortAscending is false and sortMetric is specified', () => {
|
||||
const testData = [
|
||||
{ gender: 'zebra' },
|
||||
{ gender: 'alpha' },
|
||||
{ gender: 'beta' },
|
||||
];
|
||||
|
||||
const testProps = {
|
||||
...selectMultipleProps,
|
||||
formData: {
|
||||
...selectMultipleProps.formData,
|
||||
sortMetric: 'count',
|
||||
sortAscending: false,
|
||||
},
|
||||
queriesData: [
|
||||
{
|
||||
rowcount: 3,
|
||||
colnames: ['gender'],
|
||||
coltypes: [1],
|
||||
data: testData,
|
||||
applied_filters: [{ column: 'gender' }],
|
||||
rejected_filters: [],
|
||||
},
|
||||
],
|
||||
filterState: {
|
||||
value: [],
|
||||
label: '',
|
||||
excludeFilterValues: true,
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
// @ts-ignore
|
||||
<SelectFilterPlugin
|
||||
// @ts-ignore
|
||||
{...transformProps(testProps)}
|
||||
setDataMask={jest.fn()}
|
||||
showOverflow={false}
|
||||
/>,
|
||||
{
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'test-filter': {
|
||||
name: 'Test Filter',
|
||||
},
|
||||
},
|
||||
},
|
||||
dataMask: {
|
||||
'test-filter': {
|
||||
extraFormData: {},
|
||||
filterState: {
|
||||
value: [],
|
||||
label: '',
|
||||
excludeFilterValues: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const filterSelect = screen.getAllByRole('combobox')[0];
|
||||
userEvent.click(filterSelect);
|
||||
|
||||
// When sortMetric is specified, original order should be preserved regardless
|
||||
// of sortAscending value (zebra, alpha, beta)
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options[0]).toHaveTextContent('zebra');
|
||||
expect(options[1]).toHaveTextContent('alpha');
|
||||
expect(options[2]).toHaveTextContent('beta');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -317,13 +317,20 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
|
||||
const sortComparator = useCallback(
|
||||
(a: LabeledValue, b: LabeledValue) => {
|
||||
// When sortMetric is specified, the backend already sorted the data correctly
|
||||
// Don't override the backend's metric-based sorting with frontend alphabetical sorting
|
||||
if (formData.sortMetric) {
|
||||
return 0; // Preserve the original order from the backend
|
||||
}
|
||||
|
||||
// Only apply alphabetical sorting when no sortMetric is specified
|
||||
const labelComparator = propertyComparator('label');
|
||||
if (formData.sortAscending) {
|
||||
return labelComparator(a, b);
|
||||
}
|
||||
return labelComparator(b, a);
|
||||
},
|
||||
[formData.sortAscending],
|
||||
[formData.sortAscending, formData.sortMetric],
|
||||
);
|
||||
|
||||
// Use effect for initialisation for filter plugin
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
190
superset-frontend/src/utils/cachedSupersetGet.test.ts
Normal file
190
superset-frontend/src/utils/cachedSupersetGet.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1482,9 +1482,12 @@ class ChartDataResponseResult(Schema):
|
||||
allow_none=None,
|
||||
)
|
||||
query = fields.String(
|
||||
metadata={"description": "The executed query statement"},
|
||||
required=True,
|
||||
allow_none=False,
|
||||
metadata={
|
||||
"description": "The executed query statement. May be absent when "
|
||||
"validation errors occur."
|
||||
},
|
||||
required=False,
|
||||
allow_none=True,
|
||||
)
|
||||
status = fields.String(
|
||||
metadata={"description": "Status of the query"},
|
||||
|
||||
@@ -24,6 +24,7 @@ from superset.commands.chart.exceptions import (
|
||||
ChartDataCacheLoadError,
|
||||
ChartDataQueryFailedError,
|
||||
)
|
||||
from superset.common.chart_data import ChartDataResultType
|
||||
from superset.common.query_context import QueryContext
|
||||
from superset.exceptions import CacheLoadError
|
||||
|
||||
@@ -48,8 +49,13 @@ class ChartDataCommand(BaseCommand):
|
||||
except CacheLoadError as ex:
|
||||
raise ChartDataCacheLoadError(ex.message) from ex
|
||||
|
||||
# Skip error check for query-only requests - errors are returned in payload
|
||||
# This allows View Query modal to display validation errors
|
||||
for query in payload["queries"]:
|
||||
if query.get("error"):
|
||||
if (
|
||||
query.get("error")
|
||||
and self._query_context.result_type != ChartDataResultType.QUERY
|
||||
):
|
||||
raise ChartDataQueryFailedError(
|
||||
_("Error: %(error)s", error=query["error"])
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -67,7 +67,8 @@ Explore & Analysis:
|
||||
- generate_explore_link: Create pre-configured explore URL with dataset/metrics/filters
|
||||
|
||||
System Information:
|
||||
- get_superset_instance_info: Get instance-wide statistics and metadata
|
||||
- get_instance_info: Get instance-wide statistics and metadata
|
||||
- health_check: Simple health check tool (takes NO parameters, call without arguments)
|
||||
|
||||
Available Resources:
|
||||
- superset://instance/metadata: Access instance configuration and metadata
|
||||
@@ -118,7 +119,7 @@ General usage tips:
|
||||
- All tools return structured, Pydantic-typed responses
|
||||
- Chart previews are served as PNG images via custom screenshot endpoints
|
||||
|
||||
If you are unsure which tool to use, start with get_superset_instance_info
|
||||
If you are unsure which tool to use, start with get_instance_info
|
||||
or use the superset_quickstart prompt for an interactive guide.
|
||||
"""
|
||||
|
||||
@@ -283,7 +284,7 @@ from superset.mcp_service.system import ( # noqa: F401, E402
|
||||
resources as system_resources,
|
||||
)
|
||||
from superset.mcp_service.system.tool import ( # noqa: F401, E402
|
||||
get_superset_instance_info,
|
||||
get_instance_info,
|
||||
health_check,
|
||||
)
|
||||
|
||||
|
||||
18
superset/mcp_service/system/__init__.py
Normal file
18
superset/mcp_service/system/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""System-level MCP service tools and utilities."""
|
||||
@@ -82,7 +82,7 @@ I'll help you through these steps:
|
||||
5. 🚀 **Learn Advanced Features** - Discover filters, SQL Lab, and more
|
||||
|
||||
To get started, I'll use these tools:
|
||||
- `get_superset_instance_info` - Overview of your Superset instance
|
||||
- `get_instance_info` - Overview of your Superset instance
|
||||
- `list_datasets` - Find available datasets
|
||||
- `get_dataset_info` - Explore dataset details
|
||||
- `generate_chart` - Create visualizations
|
||||
|
||||
@@ -55,7 +55,7 @@ def get_instance_metadata_resource() -> str:
|
||||
from superset.daos.user import UserDAO
|
||||
from superset.mcp_service.mcp_core import InstanceInfoCore
|
||||
from superset.mcp_service.system.schemas import InstanceInfo
|
||||
from superset.mcp_service.system.tool.get_superset_instance_info import (
|
||||
from superset.mcp_service.system.system_utils import (
|
||||
calculate_dashboard_breakdown,
|
||||
calculate_database_breakdown,
|
||||
calculate_instance_summary,
|
||||
@@ -101,7 +101,7 @@ def get_instance_metadata_resource() -> str:
|
||||
"error": "Unable to fetch complete metadata",
|
||||
"tips": [
|
||||
"Use list_datasets to explore available data",
|
||||
"Use get_superset_instance_info for basic stats",
|
||||
"Use get_instance_info for basic stats",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -45,14 +45,13 @@ class HealthCheckResponse(BaseModel):
|
||||
|
||||
class GetSupersetInstanceInfoRequest(BaseModel):
|
||||
"""
|
||||
Request schema for get_superset_instance_info tool.
|
||||
Request schema for get_instance_info tool.
|
||||
|
||||
Currently has no parameters but provides consistent API for future extensibility.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra="forbid",
|
||||
str_strip_whitespace=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,30 +16,22 @@
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Get Superset instance high-level information FastMCP tool using configurable
|
||||
InstanceInfoCore for flexible, extensible metrics calculation.
|
||||
System-level utility functions for MCP service.
|
||||
|
||||
This module contains helper functions used by system tools for calculating
|
||||
instance metrics, dashboard breakdowns, database breakdowns, and activity summaries.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastmcp import Context
|
||||
|
||||
from superset.mcp_service.app import mcp
|
||||
from superset.mcp_service.auth import mcp_auth_hook
|
||||
from superset.mcp_service.mcp_core import InstanceInfoCore
|
||||
from superset.mcp_service.system.schemas import (
|
||||
DashboardBreakdown,
|
||||
DatabaseBreakdown,
|
||||
GetSupersetInstanceInfoRequest,
|
||||
InstanceInfo,
|
||||
InstanceSummary,
|
||||
PopularContent,
|
||||
RecentActivity,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def calculate_dashboard_breakdown(
|
||||
base_counts: Dict[str, int],
|
||||
@@ -202,67 +194,3 @@ def calculate_popular_content(
|
||||
top_tags=[],
|
||||
top_creators=[],
|
||||
)
|
||||
|
||||
|
||||
# Configure the instance info core
|
||||
_instance_info_core = InstanceInfoCore(
|
||||
dao_classes={
|
||||
"dashboards": None, # type: ignore[dict-item] # Will be set at runtime
|
||||
"charts": None, # type: ignore[dict-item]
|
||||
"datasets": None, # type: ignore[dict-item]
|
||||
"databases": None, # type: ignore[dict-item]
|
||||
"users": None, # type: ignore[dict-item]
|
||||
"tags": None, # type: ignore[dict-item]
|
||||
},
|
||||
output_schema=InstanceInfo,
|
||||
metric_calculators={
|
||||
"instance_summary": calculate_instance_summary,
|
||||
"recent_activity": calculate_recent_activity,
|
||||
"dashboard_breakdown": calculate_dashboard_breakdown,
|
||||
"database_breakdown": calculate_database_breakdown,
|
||||
"popular_content": calculate_popular_content,
|
||||
},
|
||||
time_windows={
|
||||
"recent": 7,
|
||||
"monthly": 30,
|
||||
"quarterly": 90,
|
||||
},
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
@mcp_auth_hook
|
||||
def get_superset_instance_info(
|
||||
request: GetSupersetInstanceInfoRequest, ctx: Context
|
||||
) -> InstanceInfo:
|
||||
"""Get Superset instance statistics.
|
||||
|
||||
Returns counts, activity metrics, and database types.
|
||||
"""
|
||||
try:
|
||||
# Import DAOs at runtime to avoid circular imports
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.daos.database import DatabaseDAO
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
from superset.daos.tag import TagDAO
|
||||
from superset.daos.user import UserDAO
|
||||
|
||||
# Configure DAO classes at runtime
|
||||
_instance_info_core.dao_classes = {
|
||||
"dashboards": DashboardDAO,
|
||||
"charts": ChartDAO,
|
||||
"datasets": DatasetDAO,
|
||||
"databases": DatabaseDAO,
|
||||
"users": UserDAO,
|
||||
"tags": TagDAO,
|
||||
}
|
||||
|
||||
# Run the configurable core
|
||||
return _instance_info_core.run_tool()
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Unexpected error in instance info: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
raise
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
106
superset/mcp_service/system/tool/get_instance_info.py
Normal file
106
superset/mcp_service/system/tool/get_instance_info.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
(
|
||||
|
||||
@@ -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"
|
||||
|
||||
16
tests/unit_tests/charts/commands/data/__init__.py
Normal file
16
tests/unit_tests/charts/commands/data/__init__.py
Normal 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.
|
||||
325
tests/unit_tests/charts/commands/data/test_get_data_command.py
Normal file
325
tests/unit_tests/charts/commands/data/test_get_data_command.py
Normal 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"
|
||||
365
tests/unit_tests/connectors/sqla/test_cache_key_stability.py
Normal file
365
tests/unit_tests/connectors/sqla/test_cache_key_stability.py
Normal 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)"
|
||||
)
|
||||
@@ -482,7 +482,7 @@ class TestExecuteSql:
|
||||
}
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError, match="minimum of 1"):
|
||||
with pytest.raises(ToolError, match="greater than or equal to 1"):
|
||||
await client.call_tool("execute_sql", {"request": request})
|
||||
|
||||
# Test limit too high
|
||||
@@ -493,5 +493,5 @@ class TestExecuteSql:
|
||||
}
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError, match="maximum of 10000"):
|
||||
with pytest.raises(ToolError, match="less than or equal to 10000"):
|
||||
await client.call_tool("execute_sql", {"request": request})
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Tests for health_check MCP tool."""
|
||||
|
||||
from superset.mcp_service.system.schemas import HealthCheckResponse
|
||||
|
||||
|
||||
def test_health_check_response_schema():
|
||||
"""Test that HealthCheckResponse has required fields."""
|
||||
response = HealthCheckResponse(
|
||||
status="healthy",
|
||||
timestamp="2025-11-10T19:00:00",
|
||||
service="Test MCP Service",
|
||||
version="4.0.0",
|
||||
python_version="3.11.0",
|
||||
platform="Darwin",
|
||||
)
|
||||
|
||||
assert response.status == "healthy"
|
||||
assert response.service == "Test MCP Service"
|
||||
assert response.version == "4.0.0"
|
||||
assert response.python_version == "3.11.0"
|
||||
assert response.platform == "Darwin"
|
||||
assert response.timestamp is not None
|
||||
assert response.uptime_seconds is None # Optional field
|
||||
|
||||
|
||||
def test_health_check_response_with_uptime():
|
||||
"""Test HealthCheckResponse with optional uptime field."""
|
||||
response = HealthCheckResponse(
|
||||
status="healthy",
|
||||
timestamp="2025-11-10T19:00:00",
|
||||
service="Test MCP Service",
|
||||
version="4.0.0",
|
||||
python_version="3.11.0",
|
||||
platform="Darwin",
|
||||
uptime_seconds=123.45,
|
||||
)
|
||||
|
||||
assert response.uptime_seconds == 123.45
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user