mirror of
https://github.com/apache/superset.git
synced 2026-07-04 05:45:32 +00:00
Compare commits
5 Commits
issue-3607
...
fix-filter
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cdc0b7644 | ||
|
|
71ce65ba36 | ||
|
|
40274f478b | ||
|
|
eda3601fa7 | ||
|
|
ddea827242 |
@@ -83,7 +83,6 @@ 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,7 +195,6 @@ playwright-install() {
|
||||
|
||||
playwright-run() {
|
||||
local APP_ROOT=$1
|
||||
local TEST_PATH=$2
|
||||
|
||||
# Start Flask from the project root (same as Cypress)
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
@@ -239,26 +238,8 @@ playwright-run() {
|
||||
|
||||
say "::group::Run Playwright tests"
|
||||
echo "Running Playwright with baseURL: ${PLAYWRIGHT_BASE_URL}"
|
||||
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
|
||||
npx playwright test auth/login --reporter=github --output=playwright-results
|
||||
local status=$?
|
||||
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,118 +151,3 @@ 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 Experimental Tests
|
||||
name: Playwright E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -23,10 +23,9 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# 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:
|
||||
playwright-tests:
|
||||
runs-on: ubuntu-22.04
|
||||
# Allow workflow to succeed even if tests fail during shadow mode
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -118,13 +117,13 @@ jobs:
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: playwright-install
|
||||
- name: Run Playwright (Experimental Tests)
|
||||
- name: Run Playwright
|
||||
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 }}" experimental/
|
||||
run: playwright-run ${{ matrix.app_root }}
|
||||
- name: Set safe app root
|
||||
if: failure()
|
||||
id: set-safe-app-root
|
||||
@@ -139,4 +138,4 @@ jobs:
|
||||
path: |
|
||||
${{ github.workspace }}/superset-frontend/playwright-results/
|
||||
${{ github.workspace }}/superset-frontend/test-results/
|
||||
name: playwright-experimental-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
|
||||
name: playwright-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
|
||||
|
||||
@@ -166,56 +166,6 @@ 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.13.0.2"]
|
||||
fastmcp = ["fastmcp>=2.10.6"]
|
||||
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==6.2.1
|
||||
cachetools==5.5.2
|
||||
# via google-auth
|
||||
cattrs==25.1.1
|
||||
# via requests-cache
|
||||
@@ -154,13 +154,12 @@ geographiclib==2.0
|
||||
# via geopy
|
||||
geopy==2.4.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
google-auth==2.43.0
|
||||
google-auth==2.40.3
|
||||
# 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
|
||||
@@ -405,7 +404,7 @@ trio==0.30.0
|
||||
# trio-websocket
|
||||
trio-websocket==0.12.2
|
||||
# via selenium
|
||||
typing-extensions==4.15.0
|
||||
typing-extensions==4.14.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# alembic
|
||||
|
||||
@@ -58,16 +58,10 @@ 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
|
||||
@@ -89,11 +83,10 @@ cachelib==0.13.0
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-caching
|
||||
# flask-session
|
||||
cachetools==6.2.1
|
||||
cachetools==5.5.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# google-auth
|
||||
# py-key-value-aio
|
||||
cattrs==25.1.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -174,9 +167,7 @@ cryptography==44.0.3
|
||||
# apache-superset
|
||||
# authlib
|
||||
# paramiko
|
||||
# pyjwt
|
||||
# pyopenssl
|
||||
# secretstorage
|
||||
cycler==0.12.1
|
||||
# via matplotlib
|
||||
cyclopts==3.24.0
|
||||
@@ -197,8 +188,6 @@ 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
|
||||
@@ -228,7 +217,7 @@ et-xmlfile==2.0.0
|
||||
# openpyxl
|
||||
exceptiongroup==1.3.0
|
||||
# via fastmcp
|
||||
fastmcp==2.13.0.2
|
||||
fastmcp==2.10.6
|
||||
# via apache-superset
|
||||
filelock==3.12.2
|
||||
# via virtualenv
|
||||
@@ -330,7 +319,7 @@ google-api-core==2.23.0
|
||||
# google-cloud-core
|
||||
# pandas-gbq
|
||||
# sqlalchemy-bigquery
|
||||
google-auth==2.43.0
|
||||
google-auth==2.40.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# google-api-core
|
||||
@@ -368,7 +357,6 @@ greenlet==3.1.1
|
||||
# apache-superset
|
||||
# gevent
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
grpcio==1.71.0
|
||||
# via
|
||||
# apache-superset
|
||||
@@ -418,8 +406,6 @@ 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
|
||||
@@ -435,16 +421,6 @@ 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
|
||||
@@ -463,16 +439,12 @@ jsonschema==4.23.0
|
||||
# openapi-schema-validator
|
||||
# openapi-spec-validator
|
||||
jsonschema-path==0.3.4
|
||||
# via
|
||||
# fastmcp
|
||||
# openapi-spec-validator
|
||||
# via 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
|
||||
@@ -524,16 +496,12 @@ matplotlib==3.9.0
|
||||
# via prophet
|
||||
mccabe==0.7.0
|
||||
# via pylint
|
||||
mcp==1.20.0
|
||||
mcp==1.14.1
|
||||
# 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
|
||||
@@ -630,8 +598,6 @@ 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
|
||||
@@ -646,7 +612,6 @@ pip==25.1.1
|
||||
platformdirs==4.3.8
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# fastmcp
|
||||
# pylint
|
||||
# requests-cache
|
||||
# virtualenv
|
||||
@@ -689,10 +654,6 @@ 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
|
||||
@@ -748,7 +709,6 @@ pyjwt==2.10.1
|
||||
# apache-superset
|
||||
# flask-appbuilder
|
||||
# flask-jwt-extended
|
||||
# mcp
|
||||
pylint==3.3.7
|
||||
# via apache-superset
|
||||
pynacl==1.5.0
|
||||
@@ -884,8 +844,6 @@ 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
|
||||
@@ -983,7 +941,7 @@ trio-websocket==0.12.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# selenium
|
||||
typing-extensions==4.15.0
|
||||
typing-extensions==4.14.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
@@ -992,7 +950,6 @@ typing-extensions==4.15.0
|
||||
# cattrs
|
||||
# exceptiongroup
|
||||
# limits
|
||||
# py-key-value-shared
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
# pyopenssl
|
||||
@@ -1047,8 +1004,6 @@ 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
|
||||
@@ -1084,8 +1039,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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,7 +162,6 @@
|
||||
"@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",
|
||||
@@ -258,7 +257,6 @@
|
||||
"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",
|
||||
@@ -5331,7 +5329,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -10747,55 +10744,6 @@
|
||||
"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",
|
||||
@@ -19764,19 +19712,6 @@
|
||||
"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",
|
||||
@@ -25902,16 +25837,6 @@
|
||||
"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",
|
||||
@@ -40715,10 +40640,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/min-document": {
|
||||
"version": "2.19.1",
|
||||
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.1.tgz",
|
||||
"integrity": "sha512-8lqe85PkqQJzIcs2iD7xW/WSxcncC3/DPVbTOafKNJDIMXwGfwXS350mH4SJslomntN2iYtFBuC0yNO3CEap6g==",
|
||||
"license": "MIT",
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
|
||||
"integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==",
|
||||
"dependencies": {
|
||||
"dom-walk": "^0.1.0"
|
||||
}
|
||||
@@ -49557,16 +49481,6 @@
|
||||
"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",
|
||||
@@ -54125,13 +54039,6 @@
|
||||
"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",
|
||||
@@ -62020,6 +61927,14 @@
|
||||
"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",
|
||||
@@ -62028,32 +61943,19 @@
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@testing-library/dom": "^8.20.1",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^12.1.5",
|
||||
"@testing-library/react-hooks": "*",
|
||||
"@testing-library/user-event": "*",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/react": "*",
|
||||
"@types/react-loadable": "*",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/tinycolor2": "*",
|
||||
"@types/react": "^17.0.83",
|
||||
"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",
|
||||
"lodash": "^4.17.21",
|
||||
"nanoid": "^5.0.9",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-loadable": "^5.5.0",
|
||||
"tinycolor2": "*"
|
||||
"react": "^17.0.2"
|
||||
}
|
||||
},
|
||||
"packages/superset-core/node_modules/@types/lodash": {
|
||||
@@ -65850,7 +65752,6 @@
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@encodable/color": "=1.1.1",
|
||||
"@superset-ui/core": "*",
|
||||
"@superset-ui/legacy-plugin-chart-calendar": "*",
|
||||
@@ -66435,7 +66336,6 @@
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
@@ -66461,7 +66361,6 @@
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*"
|
||||
}
|
||||
@@ -66485,7 +66384,6 @@
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^17.0.2"
|
||||
@@ -66501,7 +66399,6 @@
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^17.0.2"
|
||||
@@ -66564,7 +66461,6 @@
|
||||
"supercluster": "^8.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"mapbox-gl": "*",
|
||||
@@ -66581,7 +66477,6 @@
|
||||
"reactable": "^1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^17.0.2"
|
||||
@@ -66596,7 +66491,6 @@
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^17.0.2"
|
||||
@@ -66612,7 +66506,6 @@
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@testing-library/jest-dom": "*",
|
||||
@@ -66631,7 +66524,6 @@
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
@@ -66650,7 +66542,6 @@
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^17.0.2"
|
||||
@@ -66697,7 +66588,6 @@
|
||||
"@types/urijs": "^1.19.25"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"mapbox-gl": "*",
|
||||
@@ -66829,7 +66719,6 @@
|
||||
"urijs": "^1.19.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^17.0.2"
|
||||
@@ -66888,7 +66777,6 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@apache-superset/core": "*",
|
||||
"@reduxjs/toolkit": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
@@ -66943,7 +66831,6 @@
|
||||
"jest": "^30.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"ace-builds": "^1.4.14",
|
||||
@@ -69397,7 +69284,6 @@
|
||||
"@types/d3-cloud": "^1.2.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@types/lodash": "*",
|
||||
|
||||
@@ -241,7 +241,6 @@
|
||||
"@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",
|
||||
@@ -337,7 +336,6 @@
|
||||
"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,9 +49,7 @@ const LoaderWrapper = styled.div<{
|
||||
|
||||
&.inline-centered {
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: block;
|
||||
}
|
||||
&.floating {
|
||||
position: absolute;
|
||||
|
||||
@@ -94,11 +94,6 @@ 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;
|
||||
@@ -160,7 +155,6 @@ function TableCollection<T extends object>({
|
||||
columns,
|
||||
rows,
|
||||
loading,
|
||||
highlightRowId,
|
||||
setSortBy,
|
||||
headerGroups,
|
||||
columnsForWrapText,
|
||||
@@ -278,12 +272,6 @@ function TableCollection<T extends object>({
|
||||
onPageChange,
|
||||
]);
|
||||
|
||||
const getRowClassName = useCallback(
|
||||
(record: Record<string, unknown>) =>
|
||||
record?.id === highlightRowId ? 'table-row-highlighted' : '',
|
||||
[highlightRowId],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledTable
|
||||
loading={loading}
|
||||
@@ -301,7 +289,6 @@ function TableCollection<T extends object>({
|
||||
sortDirections={['ascend', 'descend', 'ascend']}
|
||||
isPaginationSticky={isPaginationSticky}
|
||||
showRowCount={showRowCount}
|
||||
rowClassName={getRowClassName}
|
||||
components={{
|
||||
header: {
|
||||
cell: (props: HTMLAttributes<HTMLTableCellElement>) => (
|
||||
|
||||
@@ -67,7 +67,6 @@ export function normalizeTimeColumn(
|
||||
sqlExpression: formData.x_axis,
|
||||
label: formData.x_axis,
|
||||
expressionType: 'SQL',
|
||||
isColumnReference: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface AdhocColumn {
|
||||
optionName?: string;
|
||||
sqlExpression: string;
|
||||
expressionType: 'SQL';
|
||||
isColumnReference?: boolean;
|
||||
columnType?: 'BASE_AXIS' | 'SERIES';
|
||||
timeGrain?: string;
|
||||
datasourceWarning?: boolean;
|
||||
@@ -75,10 +74,6 @@ export function isAdhocColumn(column?: any): column is AdhocColumn {
|
||||
);
|
||||
}
|
||||
|
||||
export function isAdhocColumnReference(column?: any): column is AdhocColumn {
|
||||
return isAdhocColumn(column) && column?.isColumnReference === true;
|
||||
}
|
||||
|
||||
export function isQueryFormColumn(column: any): column is QueryFormColumn {
|
||||
return isPhysicalColumn(column) || isAdhocColumn(column);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,6 @@ test('should support different columns for x-axis and granularity', () => {
|
||||
{
|
||||
timeGrain: 'P1Y',
|
||||
columnType: 'BASE_AXIS',
|
||||
isColumnReference: true,
|
||||
sqlExpression: 'time_column_in_x_axis',
|
||||
label: 'time_column_in_x_axis',
|
||||
expressionType: 'SQL',
|
||||
|
||||
@@ -26,13 +26,6 @@ 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 },
|
||||
|
||||
@@ -1,70 +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.
|
||||
-->
|
||||
|
||||
# Experimental Playwright Tests
|
||||
|
||||
This directory contains Playwright tests that are still under development or validation.
|
||||
|
||||
## Purpose
|
||||
|
||||
Tests in this directory run in "shadow mode" with `continue-on-error: true` in CI:
|
||||
- Failures do NOT block PR merges
|
||||
- Allows tests to run in CI to validate stability before promotion
|
||||
- Provides visibility into test reliability over time
|
||||
|
||||
## Promoting Tests to Stable
|
||||
|
||||
Once a test has proven stable (no false positives/negatives over sufficient time):
|
||||
|
||||
1. Move the test file out of `experimental/` to the appropriate feature directory:
|
||||
```bash
|
||||
# From the repository root:
|
||||
git mv superset-frontend/playwright/tests/experimental/dashboard/test.spec.ts \
|
||||
superset-frontend/playwright/tests/dashboard/
|
||||
|
||||
# Or from the superset-frontend/ directory:
|
||||
git mv playwright/tests/experimental/dashboard/test.spec.ts \
|
||||
playwright/tests/dashboard/
|
||||
```
|
||||
|
||||
2. The test will automatically become required for merge
|
||||
|
||||
## Test Organization
|
||||
|
||||
Organize tests by feature area:
|
||||
- `auth/` - Authentication and authorization tests
|
||||
- `dashboard/` - Dashboard functionality tests
|
||||
- `explore/` - Chart builder tests
|
||||
- `sqllab/` - SQL Lab tests
|
||||
- etc.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all experimental tests (requires INCLUDE_EXPERIMENTAL env var)
|
||||
INCLUDE_EXPERIMENTAL=true npm run playwright:test -- experimental/
|
||||
|
||||
# Run specific experimental test
|
||||
INCLUDE_EXPERIMENTAL=true npm run playwright:test -- experimental/dashboard/test.spec.ts
|
||||
|
||||
# Run in UI mode for debugging
|
||||
INCLUDE_EXPERIMENTAL=true npm run playwright:ui -- experimental/
|
||||
```
|
||||
|
||||
**Note**: The `INCLUDE_EXPERIMENTAL=true` environment variable is required because experimental tests are filtered out by default in `playwright.config.ts`. Without it, Playwright will report "No tests found".
|
||||
@@ -101,35 +101,36 @@ describe('queryObject conversion', () => {
|
||||
|
||||
it('should convert queryObject', () => {
|
||||
const { queries } = buildQuery({ ...formData, x_axis: 'time_column' });
|
||||
expect(queries[0]).toMatchObject({
|
||||
granularity: 'time_column',
|
||||
time_range: '1 year ago : 2013',
|
||||
extras: { having: '', where: '', time_grain_sqla: 'P1Y' },
|
||||
columns: [
|
||||
{
|
||||
columnType: 'BASE_AXIS',
|
||||
expressionType: 'SQL',
|
||||
label: 'time_column',
|
||||
sqlExpression: 'time_column',
|
||||
timeGrain: 'P1Y',
|
||||
isColumnReference: true,
|
||||
},
|
||||
'col1',
|
||||
],
|
||||
series_columns: ['col1'],
|
||||
metrics: ['count(*)'],
|
||||
post_processing: [
|
||||
{
|
||||
operation: 'pivot',
|
||||
options: {
|
||||
aggregates: { 'count(*)': { operator: 'mean' } },
|
||||
columns: ['col1'],
|
||||
drop_missing_columns: true,
|
||||
index: ['time_column'],
|
||||
expect(queries[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
granularity: 'time_column',
|
||||
time_range: '1 year ago : 2013',
|
||||
extras: { having: '', where: '', time_grain_sqla: 'P1Y' },
|
||||
columns: [
|
||||
{
|
||||
columnType: 'BASE_AXIS',
|
||||
expressionType: 'SQL',
|
||||
label: 'time_column',
|
||||
sqlExpression: 'time_column',
|
||||
timeGrain: 'P1Y',
|
||||
},
|
||||
},
|
||||
{ operation: 'flatten' },
|
||||
],
|
||||
});
|
||||
'col1',
|
||||
],
|
||||
series_columns: ['col1'],
|
||||
metrics: ['count(*)'],
|
||||
post_processing: [
|
||||
{
|
||||
operation: 'pivot',
|
||||
options: {
|
||||
aggregates: { 'count(*)': { operator: 'mean' } },
|
||||
columns: ['col1'],
|
||||
drop_missing_columns: true,
|
||||
index: ['time_column'],
|
||||
},
|
||||
},
|
||||
{ operation: 'flatten' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,31 +139,6 @@ function cellWidth({
|
||||
return perc2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a column identifier for use in HTML id attributes and CSS selectors.
|
||||
* Replaces characters that are invalid in CSS selectors with safe alternatives.
|
||||
*
|
||||
* Note: The returned value should be prefixed with a string (e.g., "header-")
|
||||
* to ensure it forms a valid HTML ID (IDs cannot start with a digit).
|
||||
*
|
||||
* Exported for testing.
|
||||
*/
|
||||
export function sanitizeHeaderId(columnId: string): string {
|
||||
return (
|
||||
columnId
|
||||
// Semantic replacements first: preserve meaning in IDs for readability
|
||||
// (e.g., '%pct_nice' → 'percentpct_nice' instead of '_pct_nice')
|
||||
.replace(/%/g, 'percent')
|
||||
.replace(/#/g, 'hash')
|
||||
.replace(/△/g, 'delta')
|
||||
// Generic sanitization for remaining special characters
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
.replace(/_+/g, '_') // Collapse consecutive underscores
|
||||
.replace(/^_+|_+$/g, '') // Trim leading/trailing underscores
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cell left margin (offset) calculation for horizontal bar chart elements
|
||||
* when alignPositiveNegative is not set
|
||||
@@ -869,9 +844,6 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
}
|
||||
}
|
||||
|
||||
// Cache sanitized header ID to avoid recomputing it multiple times
|
||||
const headerId = sanitizeHeaderId(column.originalLabel ?? column.key);
|
||||
|
||||
return {
|
||||
id: String(i), // to allow duplicate column keys
|
||||
// must use custom accessor to allow `.` in column names
|
||||
@@ -997,7 +969,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
}
|
||||
|
||||
const cellProps = {
|
||||
'aria-labelledby': `header-${headerId}`,
|
||||
'aria-labelledby': `header-${column.key}`,
|
||||
role: 'cell',
|
||||
// show raw number in title in case of numeric values
|
||||
title: typeof value === 'number' ? String(value) : undefined,
|
||||
@@ -1084,7 +1056,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
},
|
||||
Header: ({ column: col, onClick, style, onDragStart, onDrop }) => (
|
||||
<th
|
||||
id={`header-${headerId}`}
|
||||
id={`header-${column.originalLabel}`}
|
||||
title={t('Shift + Click to sort by multiple columns')}
|
||||
className={[className, col.isSorted ? 'is-sorted' : ''].join(' ')}
|
||||
style={{
|
||||
|
||||
@@ -18,93 +18,15 @@
|
||||
*/
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@superset-ui/core/spec';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import TableChart, { sanitizeHeaderId } from '../src/TableChart';
|
||||
import TableChart from '../src/TableChart';
|
||||
import transformProps from '../src/transformProps';
|
||||
import DateWithFormatter from '../src/utils/DateWithFormatter';
|
||||
import testData from './testData';
|
||||
import { ProviderWrapper } from './testHelpers';
|
||||
|
||||
test('sanitizeHeaderId should sanitize percent sign', () => {
|
||||
expect(sanitizeHeaderId('%pct_nice')).toBe('percentpct_nice');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should sanitize hash/pound sign', () => {
|
||||
expect(sanitizeHeaderId('# metric_1')).toBe('hash_metric_1');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should sanitize delta symbol', () => {
|
||||
expect(sanitizeHeaderId('△ delta')).toBe('delta_delta');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should replace spaces with underscores', () => {
|
||||
expect(sanitizeHeaderId('Main metric_1')).toBe('Main_metric_1');
|
||||
expect(sanitizeHeaderId('multiple spaces')).toBe('multiple_spaces');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should handle multiple special characters', () => {
|
||||
expect(sanitizeHeaderId('% #△ test')).toBe('percent_hashdelta_test');
|
||||
expect(sanitizeHeaderId('% # △ test')).toBe('percent_hash_delta_test');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should preserve alphanumeric, underscore, and hyphen', () => {
|
||||
expect(sanitizeHeaderId('valid-name_123')).toBe('valid-name_123');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should replace other special characters with underscore', () => {
|
||||
expect(sanitizeHeaderId('col@name!test')).toBe('col_name_test');
|
||||
expect(sanitizeHeaderId('test.column')).toBe('test_column');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should handle edge cases', () => {
|
||||
expect(sanitizeHeaderId('')).toBe('');
|
||||
expect(sanitizeHeaderId('simple')).toBe('simple');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should collapse consecutive underscores', () => {
|
||||
expect(sanitizeHeaderId('test @@ space')).toBe('test_space');
|
||||
expect(sanitizeHeaderId('col___name')).toBe('col_name');
|
||||
expect(sanitizeHeaderId('a b c')).toBe('a_b_c');
|
||||
expect(sanitizeHeaderId('test@@name')).toBe('test_name');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should remove leading underscores', () => {
|
||||
expect(sanitizeHeaderId('@col')).toBe('col');
|
||||
expect(sanitizeHeaderId('!revenue')).toBe('revenue');
|
||||
expect(sanitizeHeaderId('@@test')).toBe('test');
|
||||
expect(sanitizeHeaderId(' leading_spaces')).toBe('leading_spaces');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should remove trailing underscores', () => {
|
||||
expect(sanitizeHeaderId('col@')).toBe('col');
|
||||
expect(sanitizeHeaderId('revenue!')).toBe('revenue');
|
||||
expect(sanitizeHeaderId('test@@')).toBe('test');
|
||||
expect(sanitizeHeaderId('trailing_spaces ')).toBe('trailing_spaces');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should remove leading and trailing underscores', () => {
|
||||
expect(sanitizeHeaderId('@col@')).toBe('col');
|
||||
expect(sanitizeHeaderId('!test!')).toBe('test');
|
||||
expect(sanitizeHeaderId(' spaced ')).toBe('spaced');
|
||||
expect(sanitizeHeaderId('@@multiple@@')).toBe('multiple');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should handle inputs with only special characters', () => {
|
||||
expect(sanitizeHeaderId('@')).toBe('');
|
||||
expect(sanitizeHeaderId('@@')).toBe('');
|
||||
expect(sanitizeHeaderId(' ')).toBe('');
|
||||
expect(sanitizeHeaderId('!@$')).toBe('');
|
||||
expect(sanitizeHeaderId('!@#$')).toBe('hash'); // # is replaced with 'hash' (semantic replacement)
|
||||
// Semantic replacements produce readable output even when alone
|
||||
expect(sanitizeHeaderId('%')).toBe('percent');
|
||||
expect(sanitizeHeaderId('#')).toBe('hash');
|
||||
expect(sanitizeHeaderId('△')).toBe('delta');
|
||||
expect(sanitizeHeaderId('% # △')).toBe('percent_hash_delta');
|
||||
});
|
||||
|
||||
describe('plugin-chart-table', () => {
|
||||
describe('transformProps', () => {
|
||||
test('should parse pageLength to pageSize', () => {
|
||||
it('should parse pageLength to pageSize', () => {
|
||||
expect(transformProps(testData.basic).pageSize).toBe(20);
|
||||
expect(
|
||||
transformProps({
|
||||
@@ -120,13 +42,13 @@ describe('plugin-chart-table', () => {
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('should memoize data records', () => {
|
||||
it('should memoize data records', () => {
|
||||
expect(transformProps(testData.basic).data).toBe(
|
||||
transformProps(testData.basic).data,
|
||||
);
|
||||
});
|
||||
|
||||
test('should memoize columns meta', () => {
|
||||
it('should memoize columns meta', () => {
|
||||
expect(transformProps(testData.basic).columns).toBe(
|
||||
transformProps({
|
||||
...testData.basic,
|
||||
@@ -135,14 +57,14 @@ describe('plugin-chart-table', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should format timestamp', () => {
|
||||
it('should format timestamp', () => {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
const parsedDate = transformProps(testData.basic).data[0]
|
||||
.__timestamp as DateWithFormatter;
|
||||
expect(String(parsedDate)).toBe('2020-01-01 12:34:56');
|
||||
expect(parsedDate.getTime()).toBe(1577882096000);
|
||||
});
|
||||
test('should process comparison columns when time_compare and comparison_type are set', () => {
|
||||
it('should process comparison columns when time_compare and comparison_type are set', () => {
|
||||
const transformedProps = transformProps(testData.comparison);
|
||||
const comparisonColumns = transformedProps.columns.filter(
|
||||
col =>
|
||||
@@ -164,7 +86,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(comparisonColumns.some(col => col.label === '%')).toBe(true);
|
||||
});
|
||||
|
||||
test('should not process comparison columns when time_compare is empty', () => {
|
||||
it('should not process comparison columns when time_compare is empty', () => {
|
||||
const propsWithoutTimeCompare = {
|
||||
...testData.comparison,
|
||||
rawFormData: {
|
||||
@@ -187,7 +109,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(comparisonColumns.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should correctly apply column configuration for comparison columns', () => {
|
||||
it('should correctly apply column configuration for comparison columns', () => {
|
||||
const transformedProps = transformProps(testData.comparisonWithConfig);
|
||||
|
||||
const comparisonColumns = transformedProps.columns.filter(
|
||||
@@ -225,7 +147,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(percentMetricConfig?.config).toEqual({ d3NumberFormat: '.3f' });
|
||||
});
|
||||
|
||||
test('should correctly format comparison columns using getComparisonColFormatter', () => {
|
||||
it('should correctly format comparison columns using getComparisonColFormatter', () => {
|
||||
const transformedProps = transformProps(testData.comparisonWithConfig);
|
||||
const comparisonColumns = transformedProps.columns.filter(
|
||||
col =>
|
||||
@@ -256,7 +178,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(formattedPercentMetric).toBe('0.123');
|
||||
});
|
||||
|
||||
test('should set originalLabel for comparison columns when time_compare and comparison_type are set', () => {
|
||||
it('should set originalLabel for comparison columns when time_compare and comparison_type are set', () => {
|
||||
const transformedProps = transformProps(testData.comparison);
|
||||
|
||||
// Check if comparison columns are processed
|
||||
@@ -343,7 +265,7 @@ describe('plugin-chart-table', () => {
|
||||
});
|
||||
|
||||
describe('TableChart', () => {
|
||||
test('render basic data', () => {
|
||||
it('render basic data', () => {
|
||||
render(
|
||||
<TableChart {...transformProps(testData.basic)} sticky={false} />,
|
||||
);
|
||||
@@ -362,9 +284,12 @@ describe('plugin-chart-table', () => {
|
||||
expect(cells[8]).toHaveTextContent('N/A');
|
||||
});
|
||||
|
||||
test('render advanced data', () => {
|
||||
it('render advanced data', () => {
|
||||
render(
|
||||
<TableChart {...transformProps(testData.advanced)} sticky={false} />,
|
||||
<>
|
||||
<TableChart {...transformProps(testData.advanced)} sticky={false} />
|
||||
,
|
||||
</>,
|
||||
);
|
||||
const secondColumnHeader = screen.getByText('Sum of Num');
|
||||
expect(secondColumnHeader).toBeInTheDocument();
|
||||
@@ -379,7 +304,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(cells[4]).toHaveTextContent('2.47k');
|
||||
});
|
||||
|
||||
test('render advanced data with currencies', () => {
|
||||
it('render advanced data with currencies', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -399,7 +324,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(cells[4]).toHaveTextContent('$ 2.47k');
|
||||
});
|
||||
|
||||
test('render data with a bigint value in a raw record mode', () => {
|
||||
it('render data with a bigint value in a raw record mode', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -420,7 +345,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(cells[3]).toHaveTextContent('1234567890123456789');
|
||||
});
|
||||
|
||||
test('render raw data', () => {
|
||||
it('render raw data', () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: { ...testData.raw.rawFormData },
|
||||
@@ -437,7 +362,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(cells[1]).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
test('render raw data with currencies', () => {
|
||||
it('render raw data with currencies', () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: {
|
||||
@@ -462,7 +387,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(cells[2]).toHaveTextContent('$ 0');
|
||||
});
|
||||
|
||||
test('render small formatted data with currencies', () => {
|
||||
it('render small formatted data with currencies', () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: {
|
||||
@@ -504,14 +429,14 @@ describe('plugin-chart-table', () => {
|
||||
expect(cells[2]).toHaveTextContent('$ 0.61');
|
||||
});
|
||||
|
||||
test('render empty data', () => {
|
||||
it('render empty data', () => {
|
||||
render(
|
||||
<TableChart {...transformProps(testData.empty)} sticky={false} />,
|
||||
);
|
||||
expect(screen.getByText('No records found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('render color with column color formatter', () => {
|
||||
it('render color with column color formatter', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -541,8 +466,8 @@ describe('plugin-chart-table', () => {
|
||||
expect(getComputedStyle(screen.getByTitle('2467')).background).toBe('');
|
||||
});
|
||||
|
||||
test('render cell without color', () => {
|
||||
const dataWithEmptyCell = cloneDeep(testData.advanced.queriesData[0]);
|
||||
it('render cell without color', () => {
|
||||
const dataWithEmptyCell = testData.advanced.queriesData[0];
|
||||
dataWithEmptyCell.data.push({
|
||||
__timestamp: null,
|
||||
name: 'Noah',
|
||||
@@ -582,7 +507,7 @@ describe('plugin-chart-table', () => {
|
||||
);
|
||||
expect(getComputedStyle(screen.getByText('N/A')).background).toBe('');
|
||||
});
|
||||
test('should display original label in grouped headers', () => {
|
||||
it('should display original label in grouped headers', () => {
|
||||
const props = transformProps(testData.comparison);
|
||||
|
||||
render(<TableChart {...props} sticky={false} />);
|
||||
@@ -597,142 +522,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(hasMetricHeaders).toBe(true);
|
||||
});
|
||||
|
||||
test('should set meaningful header IDs for time-comparison columns', () => {
|
||||
// Test time-comparison columns have proper IDs
|
||||
// Uses originalLabel (e.g., "metric_1") which is sanitized for CSS safety
|
||||
const props = transformProps(testData.comparison);
|
||||
|
||||
const { container } = render(<TableChart {...props} sticky={false} />);
|
||||
|
||||
const headers = screen.getAllByRole('columnheader');
|
||||
|
||||
// All headers should have IDs
|
||||
const headersWithIds = headers.filter(header => header.id);
|
||||
expect(headersWithIds.length).toBeGreaterThan(0);
|
||||
|
||||
// None should have "header-undefined"
|
||||
const undefinedHeaders = headersWithIds.filter(header =>
|
||||
header.id.includes('undefined'),
|
||||
);
|
||||
expect(undefinedHeaders).toHaveLength(0);
|
||||
|
||||
// Should have IDs based on sanitized originalLabel (e.g., "metric_1")
|
||||
const hasMetricHeaders = headersWithIds.some(
|
||||
header =>
|
||||
header.id.includes('metric_1') || header.id.includes('metric_2'),
|
||||
);
|
||||
expect(hasMetricHeaders).toBe(true);
|
||||
|
||||
// CRITICAL: Verify sanitization - no spaces or special chars in any header ID
|
||||
headersWithIds.forEach(header => {
|
||||
// IDs must not contain spaces (would break CSS selectors and ARIA)
|
||||
expect(header.id).not.toMatch(/\s/);
|
||||
// IDs must not contain special chars like %, #, △
|
||||
expect(header.id).not.toMatch(/[%#△]/);
|
||||
// IDs should only contain valid characters: alphanumeric, underscore, hyphen
|
||||
expect(header.id).toMatch(/^header-[a-zA-Z0-9_-]+$/);
|
||||
});
|
||||
|
||||
// CRITICAL: Verify ALL cells reference valid headers (no broken ARIA)
|
||||
const cellsWithLabels = container.querySelectorAll(
|
||||
'td[aria-labelledby]',
|
||||
);
|
||||
cellsWithLabels.forEach(cell => {
|
||||
const labelledBy = cell.getAttribute('aria-labelledby');
|
||||
if (labelledBy) {
|
||||
// Check that the ID doesn't contain spaces (would be interpreted as multiple IDs)
|
||||
expect(labelledBy).not.toMatch(/\s/);
|
||||
// Check that the ID doesn't contain special characters
|
||||
expect(labelledBy).not.toMatch(/[%#△]/);
|
||||
// Verify the referenced header actually exists
|
||||
const referencedHeader = container.querySelector(
|
||||
`#${CSS.escape(labelledBy)}`,
|
||||
);
|
||||
expect(referencedHeader).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should set meaningful header IDs for regular table columns', () => {
|
||||
// Test regular (non-time-comparison) columns have proper IDs
|
||||
// Uses fallback to column.key since originalLabel is undefined
|
||||
const props = transformProps(testData.advanced);
|
||||
|
||||
const { container } = render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
|
||||
const headers = screen.getAllByRole('columnheader');
|
||||
|
||||
// Test 1: "name" column (regular string column)
|
||||
const nameHeader = headers.find(header =>
|
||||
header.textContent?.includes('name'),
|
||||
);
|
||||
expect(nameHeader).toBeDefined();
|
||||
expect(nameHeader?.id).toBe('header-name'); // Falls back to column.key
|
||||
|
||||
// Verify cells reference this header correctly
|
||||
const nameCells = container.querySelectorAll(
|
||||
'td[aria-labelledby="header-name"]',
|
||||
);
|
||||
expect(nameCells.length).toBeGreaterThan(0);
|
||||
|
||||
// Test 2: "sum__num" column (metric with verbose map "Sum of Num")
|
||||
const sumHeader = headers.find(header =>
|
||||
header.textContent?.includes('Sum of Num'),
|
||||
);
|
||||
expect(sumHeader).toBeDefined();
|
||||
expect(sumHeader?.id).toBe('header-sum_num'); // Falls back to column.key, consecutive underscores collapsed
|
||||
|
||||
// Verify cells reference this header correctly
|
||||
const sumCells = container.querySelectorAll(
|
||||
'td[aria-labelledby="header-sum_num"]',
|
||||
);
|
||||
expect(sumCells.length).toBeGreaterThan(0);
|
||||
|
||||
// Test 3: Verify NO headers have "undefined" in their ID
|
||||
const undefinedHeaders = headers.filter(header =>
|
||||
header.id?.includes('undefined'),
|
||||
);
|
||||
expect(undefinedHeaders).toHaveLength(0);
|
||||
|
||||
// Test 4: Verify ALL headers have proper IDs (no missing IDs)
|
||||
const headersWithIds = headers.filter(header => header.id);
|
||||
expect(headersWithIds.length).toBe(headers.length);
|
||||
|
||||
// Test 5: Verify ALL header IDs are properly sanitized
|
||||
headersWithIds.forEach(header => {
|
||||
// IDs must not contain spaces
|
||||
expect(header.id).not.toMatch(/\s/);
|
||||
// IDs must not contain special chars like % (from %pct_nice column)
|
||||
expect(header.id).not.toMatch(/[%#△]/);
|
||||
// IDs should only contain valid CSS selector characters
|
||||
expect(header.id).toMatch(/^header-[a-zA-Z0-9_-]+$/);
|
||||
});
|
||||
|
||||
// Test 6: Verify ALL cells reference valid headers (no broken ARIA)
|
||||
const cellsWithLabels = container.querySelectorAll(
|
||||
'td[aria-labelledby]',
|
||||
);
|
||||
cellsWithLabels.forEach(cell => {
|
||||
const labelledBy = cell.getAttribute('aria-labelledby');
|
||||
if (labelledBy) {
|
||||
// Verify no spaces (would be interpreted as multiple IDs)
|
||||
expect(labelledBy).not.toMatch(/\s/);
|
||||
// Verify no special characters
|
||||
expect(labelledBy).not.toMatch(/[%#△]/);
|
||||
// Verify the referenced header actually exists
|
||||
const referencedHeader = container.querySelector(
|
||||
`#${CSS.escape(labelledBy)}`,
|
||||
);
|
||||
expect(referencedHeader).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('render cell bars properly, and only when it is toggled on in both regular and percent metrics', () => {
|
||||
it('render cell bars properly, and only when it is toggled on in both regular and percent metrics', () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: { ...testData.raw.rawFormData },
|
||||
@@ -782,7 +572,7 @@ describe('plugin-chart-table', () => {
|
||||
cells = document.querySelectorAll('td');
|
||||
});
|
||||
|
||||
test('render color with string column color formatter(operator begins with)', () => {
|
||||
it('render color with string column color formatter(operator begins with)', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -814,7 +604,7 @@ describe('plugin-chart-table', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('render color with string column color formatter (operator ends with)', () => {
|
||||
it('render color with string column color formatter (operator ends with)', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -843,7 +633,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(getComputedStyle(screen.getByText('Joe')).background).toBe('');
|
||||
});
|
||||
|
||||
test('render color with string column color formatter (operator containing)', () => {
|
||||
it('render color with string column color formatter (operator containing)', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -872,7 +662,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(getComputedStyle(screen.getByText('Joe')).background).toBe('');
|
||||
});
|
||||
|
||||
test('render color with string column color formatter (operator not containing)', () => {
|
||||
it('render color with string column color formatter (operator not containing)', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -903,7 +693,7 @@ describe('plugin-chart-table', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('render color with string column color formatter (operator =)', () => {
|
||||
it('render color with string column color formatter (operator =)', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -934,7 +724,7 @@ describe('plugin-chart-table', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('render color with string column color formatter (operator None)', () => {
|
||||
it('render color with string column color formatter (operator None)', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -967,7 +757,7 @@ describe('plugin-chart-table', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('render color with column color formatter to entire row', () => {
|
||||
it('render color with column color formatter to entire row', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -1003,7 +793,7 @@ describe('plugin-chart-table', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('display text color using column color formatter', () => {
|
||||
it('display text color using column color formatter', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -1036,7 +826,7 @@ describe('plugin-chart-table', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('display text color using column color formatter for entire row', () => {
|
||||
it('display text color using column color formatter for entire row', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
|
||||
@@ -950,13 +950,7 @@ export function mergeTable(table, query, prepend) {
|
||||
return { type: MERGE_TABLE, table, query, prepend };
|
||||
}
|
||||
|
||||
export function addTable(
|
||||
queryEditor,
|
||||
tableName,
|
||||
catalogName,
|
||||
schemaName,
|
||||
expanded = true,
|
||||
) {
|
||||
export function addTable(queryEditor, tableName, catalogName, schemaName) {
|
||||
return function (dispatch, getState) {
|
||||
const { dbId } = getUpToDateQuery(getState(), queryEditor, queryEditor.id);
|
||||
const table = {
|
||||
@@ -970,7 +964,7 @@ export function addTable(
|
||||
mergeTable({
|
||||
...table,
|
||||
id: nanoid(11),
|
||||
expanded,
|
||||
expanded: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -153,7 +153,6 @@ export function useKeywords(
|
||||
data.value,
|
||||
catalog,
|
||||
schema,
|
||||
false, // Don't auto-expand/switch tabs when adding via autocomplete
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,10 +61,6 @@ 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', () => {
|
||||
@@ -340,42 +336,4 @@ 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,7 +59,6 @@ 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;
|
||||
@@ -171,9 +170,6 @@ const updateDataset = async (
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
clearDatasetCache(datasetId);
|
||||
|
||||
return data.json.result;
|
||||
};
|
||||
|
||||
@@ -351,17 +347,15 @@ export const SaveDatasetModal = ({
|
||||
datasourceName: datasetName,
|
||||
}),
|
||||
)
|
||||
.then((data: { id: number }) => {
|
||||
clearDatasetCache(data.id);
|
||||
|
||||
return postFormData(data.id, 'table', {
|
||||
.then((data: { id: number }) =>
|
||||
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,14 +36,6 @@ 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';
|
||||
@@ -73,11 +65,13 @@ const TemplateParamsEditor = ({
|
||||
|
||||
const modalBody = (
|
||||
<div>
|
||||
<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')}
|
||||
<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')}
|
||||
<a
|
||||
href="https://superset.apache.org/sqllab.html#templating-with-jinja"
|
||||
target="_blank"
|
||||
@@ -86,7 +80,7 @@ const TemplateParamsEditor = ({
|
||||
{t('Jinja templating')}
|
||||
</a>{' '}
|
||||
{t('syntax.')}
|
||||
</StyledParagraph>
|
||||
</p>
|
||||
<StyledConfigEditor
|
||||
mode={language}
|
||||
minLines={25}
|
||||
|
||||
@@ -222,10 +222,8 @@ 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)),
|
||||
...(at.expanded && { activeSouthPaneTab: at.id }),
|
||||
};
|
||||
let newState = addToArr(state, 'tables', at, Boolean(action.prepend));
|
||||
newState.activeSouthPaneTab = at.id;
|
||||
if (action.query) {
|
||||
newState = alterInArr(newState, 'tables', at, {
|
||||
dataPreviewQueryId: action.query.id,
|
||||
|
||||
@@ -370,93 +370,6 @@ 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,7 +193,6 @@ 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,7 +71,6 @@ 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';
|
||||
@@ -841,9 +840,6 @@ 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,10 +103,6 @@ export const URL_PARAMS = {
|
||||
name: 'focused_chart',
|
||||
type: 'number',
|
||||
},
|
||||
editMode: {
|
||||
name: 'edit',
|
||||
type: 'boolean',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const RESERVED_CHART_URL_PARAMS: string[] = [
|
||||
@@ -121,7 +117,6 @@ 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,85 +275,6 @@ 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<{ filterBarWidth: number }>`
|
||||
${({ theme, filterBarWidth }) => css`
|
||||
const StyledHeader = styled.div`
|
||||
${({ theme }) => css`
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 99;
|
||||
max-width: calc(100vw - ${filterBarWidth}px);
|
||||
max-width: 100vw;
|
||||
|
||||
.empty-droptarget:before {
|
||||
position: absolute;
|
||||
@@ -419,9 +419,6 @@ const DashboardBuilder = () => {
|
||||
isReport;
|
||||
|
||||
const [barTopOffset, setBarTopOffset] = useState(0);
|
||||
const [currentFilterBarWidth, setCurrentFilterBarWidth] = useState(
|
||||
CLOSED_FILTER_BAR_WIDTH,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setBarTopOffset(headerRef.current?.getBoundingClientRect()?.height || 0);
|
||||
@@ -519,7 +516,6 @@ const DashboardBuilder = () => {
|
||||
shouldFocus={shouldFocusTabs}
|
||||
menuItems={[
|
||||
<IconButton
|
||||
key="collapse-tabs"
|
||||
icon={<Icons.FallOutlined iconSize="xl" />}
|
||||
label={t('Collapse tab content')}
|
||||
onClick={handleDeleteTopLevelTabs}
|
||||
@@ -563,9 +559,6 @@ const DashboardBuilder = () => {
|
||||
const filterBarWidth = dashboardFiltersOpen
|
||||
? adjustedWidth
|
||||
: CLOSED_FILTER_BAR_WIDTH;
|
||||
if (filterBarWidth !== currentFilterBarWidth) {
|
||||
setCurrentFilterBarWidth(filterBarWidth);
|
||||
}
|
||||
return (
|
||||
<FiltersPanel
|
||||
width={filterBarWidth}
|
||||
@@ -598,30 +591,23 @@ const DashboardBuilder = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const isVerticalFilterBarVisible =
|
||||
showFilterBar && filterBarOrientation === FilterBarOrientation.Vertical;
|
||||
const headerFilterBarWidth = isVerticalFilterBarVisible
|
||||
? currentFilterBarWidth
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<DashboardWrapper>
|
||||
{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}
|
||||
>
|
||||
{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}>
|
||||
{/* @ts-ignore */}
|
||||
<Droppable
|
||||
data-test="top-level-tabs"
|
||||
|
||||
@@ -1,389 +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 { 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,9 +103,6 @@ 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>(
|
||||
@@ -195,13 +192,7 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
|
||||
};
|
||||
});
|
||||
dispatch(setInScopeStatusOfFilters(scopes));
|
||||
}, [
|
||||
chartIds,
|
||||
JSON.stringify(nativeFilterScopes),
|
||||
dashboardLayout,
|
||||
dispatch,
|
||||
JSON.stringify(nativeFilters),
|
||||
]);
|
||||
}, [chartIds, JSON.stringify(nativeFilterScopes), dashboardLayout, dispatch]);
|
||||
|
||||
const childIds: string[] = useMemo(
|
||||
() => (topLevelTabs ? topLevelTabs.children : [DASHBOARD_GRID_ID]),
|
||||
|
||||
@@ -616,115 +616,4 @@ 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,28 +586,20 @@ const PropertiesModal = ({
|
||||
sections: modalSections,
|
||||
});
|
||||
|
||||
const isDataReady = !isLoading && dashboardInfo;
|
||||
|
||||
// Validate basic section when title changes or data loads
|
||||
// Validate basic section when title changes
|
||||
useEffect(() => {
|
||||
if (isDataReady) {
|
||||
validateSection('basic');
|
||||
}
|
||||
}, [dashboardTitle, validateSection, isDataReady]);
|
||||
validateSection('basic');
|
||||
}, [dashboardTitle, validateSection]);
|
||||
|
||||
// Validate advanced section when JSON changes or data loads
|
||||
// Validate advanced section when JSON changes
|
||||
useEffect(() => {
|
||||
if (isDataReady) {
|
||||
validateSection('advanced');
|
||||
}
|
||||
}, [jsonMetadata, validateSection, isDataReady]);
|
||||
validateSection('advanced');
|
||||
}, [jsonMetadata, validateSection]);
|
||||
|
||||
// Validate refresh section when frequency changes or data loads
|
||||
// Validate refresh section when refresh frequency changes
|
||||
useEffect(() => {
|
||||
if (isDataReady) {
|
||||
validateSection('refresh');
|
||||
}
|
||||
}, [refreshFrequency, validateSection, isDataReady]);
|
||||
validateSection('refresh');
|
||||
}, [refreshFrequency, validateSection]);
|
||||
|
||||
return (
|
||||
<StandardModal
|
||||
@@ -638,9 +630,7 @@ const PropertiesModal = ({
|
||||
onFinish={onFinish}
|
||||
onFieldsChange={() => {
|
||||
// Re-validate sections when form fields change
|
||||
if (isDataReady) {
|
||||
validateSection('basic');
|
||||
}
|
||||
setTimeout(() => validateSection('basic'), 100);
|
||||
}}
|
||||
data-test="dashboard-edit-properties-form"
|
||||
layout="vertical"
|
||||
|
||||
@@ -152,6 +152,7 @@ const ActionButtons = ({
|
||||
<Button
|
||||
disabled={!isClearAllEnabled}
|
||||
buttonStyle="link"
|
||||
buttonSize="small"
|
||||
className="filter-clear-all-button"
|
||||
onClick={onClearAll}
|
||||
{...getFilterBarTestId('clear-button')}
|
||||
|
||||
@@ -53,7 +53,6 @@ const VerticalFilterControlTitle = styled.h4`
|
||||
const HorizontalFilterControlTitle = styled(VerticalFilterControlTitle)`
|
||||
font-weight: ${({ theme }) => theme.fontWeightNormal};
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
margin: 0;
|
||||
${truncationCSS};
|
||||
`;
|
||||
|
||||
@@ -61,7 +60,6 @@ const HorizontalOverflowFilterControlTitle = styled(
|
||||
HorizontalFilterControlTitle,
|
||||
)`
|
||||
max-width: none;
|
||||
margin: ${({ theme }) => `${theme.sizeUnit * 2}px 0 ${theme.sizeUnit}px`};
|
||||
`;
|
||||
|
||||
const VerticalFilterControlTitleBox = styled.div`
|
||||
@@ -164,7 +162,7 @@ const HorizontalFormItem = styled(StyledFormItem)<{
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&& > .ant-row > .ant-form-item-label {
|
||||
.ant-form-item-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
|
||||
@@ -62,15 +62,7 @@ import { useFilterOutlined } from '../useFilterOutlined';
|
||||
const HEIGHT = 32;
|
||||
|
||||
// Overrides superset-ui height with min-height
|
||||
const StyledDiv = styled.div<{
|
||||
orientation: FilterBarOrientation;
|
||||
overflow: boolean;
|
||||
}>`
|
||||
padding-bottom: ${({ theme, orientation, overflow }) =>
|
||||
orientation === FilterBarOrientation.Horizontal && !overflow
|
||||
? 0
|
||||
: (theme?.sizeUnit ?? 4)}px;
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
& > div {
|
||||
height: auto !important;
|
||||
min-height: ${HEIGHT}px;
|
||||
@@ -361,11 +353,7 @@ const FilterValue: FC<FilterControlProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledDiv
|
||||
data-test="form-item-value"
|
||||
orientation={orientation}
|
||||
overflow={overflow}
|
||||
>
|
||||
<StyledDiv data-test="form-item-value">
|
||||
{isLoading ? (
|
||||
<Loading position="inline-centered" size="s" muted />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,920 @@
|
||||
/**
|
||||
* 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 { act, render, waitFor } from 'spec/helpers/testing-library';
|
||||
import { FormInstance } from 'antd/lib/form';
|
||||
import { Preset, isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
import { waitForAsyncData } from 'src/middleware/asyncEvent';
|
||||
import chartQueries from 'spec/fixtures/mockChartQueries';
|
||||
import mockDatasource, { datasourceId, id } from 'spec/fixtures/mockDatasource';
|
||||
import {
|
||||
SelectFilterPlugin,
|
||||
RangeFilterPlugin,
|
||||
TimeFilterPlugin,
|
||||
TimeColumnFilterPlugin,
|
||||
TimeGrainFilterPlugin,
|
||||
} from 'src/filters/components';
|
||||
import FiltersConfigForm, { FiltersConfigFormProps } from './FiltersConfigForm';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('src/components/Chart/chartAction');
|
||||
jest.mock('src/middleware/asyncEvent');
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetChartDataRequest = getChartDataRequest as jest.Mock;
|
||||
const mockWaitForAsyncData = waitForAsyncData as jest.Mock;
|
||||
const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
|
||||
// Type for chart data response structure
|
||||
type MockChartDataResponse = {
|
||||
response: { status: number };
|
||||
json: {
|
||||
result: Array<{
|
||||
status?: string;
|
||||
data?: unknown[];
|
||||
applied_filters?: unknown[];
|
||||
channel_id?: string;
|
||||
job_id?: string;
|
||||
user_id?: string;
|
||||
errors?: unknown[];
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
// Register filter plugins
|
||||
class FilterPreset extends Preset {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'Filter plugins',
|
||||
plugins: [
|
||||
new SelectFilterPlugin().configure({ key: 'filter_select' }),
|
||||
new RangeFilterPlugin().configure({ key: 'filter_range' }),
|
||||
new TimeFilterPlugin().configure({ key: 'filter_time' }),
|
||||
new TimeColumnFilterPlugin().configure({ key: 'filter_timecolumn' }),
|
||||
new TimeGrainFilterPlugin().configure({ key: 'filter_timegrain' }),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Test fixtures
|
||||
const defaultState = () => ({
|
||||
datasources: { ...mockDatasource },
|
||||
charts: chartQueries,
|
||||
dashboardLayout: {
|
||||
present: {},
|
||||
past: [],
|
||||
future: [],
|
||||
},
|
||||
dashboardInfo: {
|
||||
id: 123,
|
||||
},
|
||||
});
|
||||
|
||||
function createMockChartResponse(
|
||||
data = [{ name: 'Aaron', count: 453 }],
|
||||
): MockChartDataResponse {
|
||||
return {
|
||||
response: { status: 200 },
|
||||
json: {
|
||||
result: [
|
||||
{
|
||||
status: 'success',
|
||||
data,
|
||||
applied_filters: [{ column: 'name' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMockAsyncChartResponse(): MockChartDataResponse {
|
||||
return {
|
||||
response: { status: 202 },
|
||||
json: {
|
||||
result: [
|
||||
{
|
||||
channel_id: 'test-channel-123',
|
||||
job_id: 'test-job-456',
|
||||
user_id: '1',
|
||||
status: 'pending',
|
||||
errors: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFormInstance(): FormInstance {
|
||||
const formData: Record<string, unknown> = {};
|
||||
|
||||
const formInstance = {
|
||||
getFieldValue: jest.fn((path: string | string[]) => {
|
||||
const keys = Array.isArray(path) ? path : [path];
|
||||
let value: unknown = formData;
|
||||
for (const key of keys) {
|
||||
value = (value as Record<string, unknown>)?.[key];
|
||||
}
|
||||
// Return a deep copy to ensure new object references
|
||||
return value ? JSON.parse(JSON.stringify(value)) : value;
|
||||
}),
|
||||
getFieldsValue: jest.fn(() => JSON.parse(JSON.stringify(formData))),
|
||||
getFieldError: jest.fn(() => []),
|
||||
getFieldsError: jest.fn(() => []),
|
||||
getFieldWarning: jest.fn(() => []),
|
||||
isFieldsTouched: jest.fn(() => false),
|
||||
isFieldTouched: jest.fn(() => false),
|
||||
isFieldValidating: jest.fn(() => false),
|
||||
isFieldsValidating: jest.fn(() => false),
|
||||
resetFields: jest.fn(),
|
||||
setFields: jest.fn(
|
||||
(fields: Array<{ name: string | string[]; value: unknown }>) => {
|
||||
fields.forEach(field => {
|
||||
const keys = Array.isArray(field.name) ? field.name : [field.name];
|
||||
let target: Record<string, unknown> = formData;
|
||||
// eslint-disable-next-line no-plusplus
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!target[keys[i]]) target[keys[i]] = {};
|
||||
target = target[keys[i]] as Record<string, unknown>;
|
||||
}
|
||||
target[keys[keys.length - 1]] = field.value;
|
||||
});
|
||||
},
|
||||
),
|
||||
setFieldValue: jest.fn((path: string | string[], value: unknown) => {
|
||||
const keys = Array.isArray(path) ? path : [path];
|
||||
let target: Record<string, unknown> = formData;
|
||||
// eslint-disable-next-line no-plusplus
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!target[keys[i]]) target[keys[i]] = {};
|
||||
target = target[keys[i]] as Record<string, unknown>;
|
||||
}
|
||||
target[keys[keys.length - 1]] = value;
|
||||
}),
|
||||
setFieldsValue: jest.fn((values: Record<string, unknown>) => {
|
||||
Object.assign(formData, values);
|
||||
}),
|
||||
validateFields: jest.fn().mockResolvedValue({}),
|
||||
submit: jest.fn(),
|
||||
scrollToField: jest.fn(),
|
||||
getInternalHooks: jest.fn(() => ({
|
||||
dispatch: jest.fn(),
|
||||
initEntityValue: jest.fn(),
|
||||
registerField: jest.fn(),
|
||||
useSubscribe: jest.fn(),
|
||||
setInitialValues: jest.fn(),
|
||||
setCallbacks: jest.fn(),
|
||||
setValidateMessages: jest.fn(),
|
||||
getFields: jest.fn(() => []),
|
||||
setPreserve: jest.fn(),
|
||||
getInitialValue: jest.fn(),
|
||||
})),
|
||||
__INTERNAL__: {
|
||||
name: 'test-form',
|
||||
},
|
||||
focusField: jest.fn(),
|
||||
getFieldInstance: jest.fn(),
|
||||
} as FormInstance;
|
||||
|
||||
return formInstance;
|
||||
}
|
||||
|
||||
function createDefaultProps(form: FormInstance): FiltersConfigFormProps {
|
||||
return {
|
||||
expanded: false,
|
||||
filterId: 'NATIVE_FILTER-1',
|
||||
removedFilters: {},
|
||||
restoreFilter: jest.fn(),
|
||||
onModifyFilter: jest.fn(),
|
||||
form,
|
||||
getAvailableFilters: jest.fn(() => []),
|
||||
handleActiveFilterPanelChange: jest.fn(),
|
||||
activeFilterPanelKeys: [],
|
||||
isActive: true,
|
||||
setErroredFilters: jest.fn(),
|
||||
validateDependencies: jest.fn(),
|
||||
getDependencySuggestion: jest.fn(() => ''),
|
||||
};
|
||||
}
|
||||
|
||||
let form: FormInstance;
|
||||
|
||||
beforeAll(() => {
|
||||
// Register filter plugins for the test suite
|
||||
new FilterPreset().register();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
form = createFormInstance();
|
||||
// Default to synchronous queries (no GlobalAsyncQueries)
|
||||
mockIsFeatureEnabled.mockImplementation(
|
||||
(flag: FeatureFlag) => flag !== FeatureFlag.GlobalAsyncQueries,
|
||||
);
|
||||
// Default mock response
|
||||
mockGetChartDataRequest.mockResolvedValue(createMockChartResponse());
|
||||
});
|
||||
|
||||
test('should render without crashing with required props', () => {
|
||||
const props = createDefaultProps(form);
|
||||
const { container } = render(<FiltersConfigForm {...props} />, {
|
||||
initialState: defaultState(),
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle synchronous query responses correctly', async () => {
|
||||
const testData = [
|
||||
{ name: 'Alice', count: 100 },
|
||||
{ name: 'Bob', count: 200 },
|
||||
];
|
||||
mockGetChartDataRequest.mockResolvedValue(createMockChartResponse(testData));
|
||||
|
||||
const props = createDefaultProps(form);
|
||||
const { filterId } = props;
|
||||
|
||||
// Set up form with dataset and column to trigger refresh
|
||||
act(() => {
|
||||
form.setFieldsValue({
|
||||
filters: {
|
||||
[filterId]: {
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: id }, // numeric ID: 7
|
||||
column: 'name',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
render(<FiltersConfigForm {...props} />, {
|
||||
initialState: defaultState(),
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Wait for the refresh to complete
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify waitForAsyncData was NOT called for sync response
|
||||
expect(mockWaitForAsyncData).not.toHaveBeenCalled();
|
||||
|
||||
// Verify form was updated with the response data
|
||||
const formValues = form.getFieldValue('filters')?.[filterId];
|
||||
expect(formValues.defaultValueQueriesData).toBeDefined();
|
||||
});
|
||||
|
||||
test('should refresh filter data when dataset changes without default value enabled', async () => {
|
||||
const filterId = 'NATIVE_FILTER-1';
|
||||
|
||||
// Test with dataset A (use numeric ID, not datasourceId string)
|
||||
form.setFieldsValue({
|
||||
filters: {
|
||||
[filterId]: {
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: id }, // numeric ID: 7
|
||||
column: 'name',
|
||||
controlValues: {
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const propsA = createDefaultProps(form);
|
||||
const { unmount } = render(<FiltersConfigForm {...propsA} />, {
|
||||
initialState: defaultState(),
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Wait for initial refresh with dataset A
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify correct dataset ID in the API call for dataset A
|
||||
const firstCall = mockGetChartDataRequest.mock.calls[0];
|
||||
expect(firstCall[0].formData.datasource).toBe(datasourceId);
|
||||
|
||||
const callsAfterDatasetA = mockGetChartDataRequest.mock.calls.length;
|
||||
unmount();
|
||||
|
||||
// Now test with dataset B - creates new component instance
|
||||
const newDatasetNumericId = id + 1; // 8
|
||||
form.setFieldsValue({
|
||||
filters: {
|
||||
[filterId]: {
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: newDatasetNumericId }, // Pass numeric ID
|
||||
column: 'name',
|
||||
controlValues: {
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const propsB = createDefaultProps(form);
|
||||
render(<FiltersConfigForm {...propsB} />, {
|
||||
initialState: defaultState(),
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Verify refresh called again with correct dataset B
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest.mock.calls.length).toBeGreaterThan(
|
||||
callsAfterDatasetA,
|
||||
);
|
||||
});
|
||||
|
||||
// Verify the new call has the updated dataset ID
|
||||
const lastCallB =
|
||||
mockGetChartDataRequest.mock.calls[
|
||||
mockGetChartDataRequest.mock.calls.length - 1
|
||||
];
|
||||
expect(lastCallB[0].formData.datasource).toBe(
|
||||
`${newDatasetNumericId}__table`,
|
||||
);
|
||||
});
|
||||
|
||||
test('should refresh when dataset changes even with default value enabled', async () => {
|
||||
const filterId = 'NATIVE_FILTER-1';
|
||||
|
||||
// Test with dataset A WITH default value
|
||||
form.setFieldsValue({
|
||||
filters: {
|
||||
[filterId]: {
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: id }, // numeric ID: 7
|
||||
column: 'name',
|
||||
controlValues: {
|
||||
enableEmptyFilter: true, // This enables "Filter has default value"
|
||||
defaultToFirstItem: false,
|
||||
},
|
||||
defaultDataMask: {
|
||||
filterState: {
|
||||
value: ['Aaron'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const propsA = createDefaultProps(form);
|
||||
const { unmount } = render(<FiltersConfigForm {...propsA} />, {
|
||||
initialState: defaultState(),
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Wait for initial refresh
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const callsAfterDatasetA = mockGetChartDataRequest.mock.calls.length;
|
||||
unmount();
|
||||
|
||||
// CRITICAL TEST: Change to dataset B - verifies the bug fix
|
||||
// Before fix: hasDefaultValue would block refresh
|
||||
// After fix: refresh happens regardless of hasDefaultValue
|
||||
const newDatasetNumericId = id + 1; // 8
|
||||
form.setFieldsValue({
|
||||
filters: {
|
||||
[filterId]: {
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: newDatasetNumericId }, // numeric ID: 8
|
||||
column: 'name',
|
||||
controlValues: {
|
||||
enableEmptyFilter: true,
|
||||
defaultToFirstItem: false,
|
||||
},
|
||||
defaultDataMask: {
|
||||
filterState: {
|
||||
value: ['Aaron'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const propsB = createDefaultProps(form);
|
||||
render(<FiltersConfigForm {...propsB} />, {
|
||||
initialState: defaultState(),
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Verify refresh called again with dataset B
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest.mock.calls.length).toBeGreaterThan(
|
||||
callsAfterDatasetA,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should refresh when column changes and verify isDataDirty state transition', async () => {
|
||||
const filterId = 'NATIVE_FILTER-1';
|
||||
|
||||
// Initial setup with column 'name'
|
||||
form.setFieldsValue({
|
||||
filters: {
|
||||
[filterId]: {
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: id }, // numeric ID: 7
|
||||
column: 'name',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const propsA = createDefaultProps(form);
|
||||
const { unmount } = render(<FiltersConfigForm {...propsA} />, {
|
||||
initialState: defaultState(),
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Wait for initial refresh
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify column 'name' in first call
|
||||
const firstCall = mockGetChartDataRequest.mock.calls[0];
|
||||
expect(firstCall[0].formData.groupby).toEqual(['name']);
|
||||
|
||||
const initialCallCount = mockGetChartDataRequest.mock.calls.length;
|
||||
unmount();
|
||||
|
||||
// Change to column 'gender' - should set isDataDirty
|
||||
form.setFieldsValue({
|
||||
filters: {
|
||||
[filterId]: {
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: id }, // numeric ID: 7
|
||||
column: 'gender',
|
||||
isDataDirty: true, // useBackendFormUpdate hook would set this
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const propsB = createDefaultProps(form);
|
||||
render(<FiltersConfigForm {...propsB} />, {
|
||||
initialState: defaultState(),
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Verify refresh called again after column change
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest.mock.calls.length).toBeGreaterThan(
|
||||
initialCallCount,
|
||||
);
|
||||
});
|
||||
|
||||
// Verify the new call has updated column
|
||||
const lastCall =
|
||||
mockGetChartDataRequest.mock.calls[
|
||||
mockGetChartDataRequest.mock.calls.length - 1
|
||||
];
|
||||
expect(lastCall[0].formData.groupby).toEqual(['gender']);
|
||||
|
||||
// Verify isDataDirty was reset to false after refresh completed
|
||||
await waitFor(() => {
|
||||
const finalFormValues = form.getFieldValue('filters')?.[filterId];
|
||||
// After refreshHandler completes, isDataDirty should be false
|
||||
expect(finalFormValues.isDataDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle async query responses with polling', async () => {
|
||||
// Enable GlobalAsyncQueries feature flag
|
||||
mockIsFeatureEnabled.mockImplementation(
|
||||
(flag: FeatureFlag) => flag === FeatureFlag.GlobalAsyncQueries,
|
||||
);
|
||||
|
||||
// Mock async response (202)
|
||||
mockGetChartDataRequest.mockResolvedValue(createMockAsyncChartResponse());
|
||||
|
||||
// Mock successful polling result
|
||||
const asyncData = [
|
||||
{
|
||||
status: 'success',
|
||||
data: [{ name: 'Async Result', count: 999 }],
|
||||
applied_filters: [],
|
||||
},
|
||||
];
|
||||
mockWaitForAsyncData.mockResolvedValue(asyncData);
|
||||
|
||||
const props = createDefaultProps(form);
|
||||
const { filterId } = props;
|
||||
|
||||
act(() => {
|
||||
form.setFieldsValue({
|
||||
filters: {
|
||||
[filterId]: {
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: id }, // numeric ID: 7
|
||||
column: 'name',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
render(<FiltersConfigForm {...props} />, {
|
||||
initialState: defaultState(),
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Verify async flow
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWaitForAsyncData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify final data is set
|
||||
await waitFor(() => {
|
||||
const formValues = form.getFieldValue('filters')?.[filterId];
|
||||
expect(formValues.defaultValueQueriesData).toEqual(asyncData);
|
||||
});
|
||||
});
|
||||
|
||||
test('should display error when API request fails', async () => {
|
||||
// Mock API error with a simple error object
|
||||
const mockError = {
|
||||
error: 'Internal Server Error',
|
||||
message: 'An error occurred',
|
||||
statusText: 'Internal Server Error',
|
||||
};
|
||||
mockGetChartDataRequest.mockRejectedValue(mockError);
|
||||
|
||||
const filterId = 'NATIVE_FILTER-1';
|
||||
|
||||
form.setFieldsValue({
|
||||
filters: {
|
||||
[filterId]: {
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: id }, // numeric ID: 7
|
||||
column: 'name',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const props = createDefaultProps(form);
|
||||
const { container } = render(<FiltersConfigForm {...props} />, {
|
||||
initialState: defaultState(),
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait a bit for error handling to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Component should still be rendered (no crash)
|
||||
expect(container).toBeInTheDocument();
|
||||
|
||||
// defaultValueQueriesData should remain null on error
|
||||
const formValues = form.getFieldValue('filters')?.[filterId];
|
||||
expect(formValues.defaultValueQueriesData).toBeNull();
|
||||
|
||||
// Verify API was called with correct parameters
|
||||
expect(mockGetChartDataRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
formData: expect.objectContaining({
|
||||
datasource: datasourceId,
|
||||
groupby: ['name'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Note: setErroredFilters is only called during form validation, NOT during refresh errors
|
||||
// The refresh error handler calls setError/setErrorWrapper instead
|
||||
// Verify error UI is displayed
|
||||
await waitFor(() => {
|
||||
const formValues = form.getFieldValue('filters')?.[filterId];
|
||||
// The component sets error through setError() which isn't reflected in form state
|
||||
// But we've verified the component didn't crash and defaultValueQueriesData is null
|
||||
expect(formValues.defaultValueQueriesData).toBeNull();
|
||||
});
|
||||
|
||||
// TODO: Once component properly renders error state, add this assertion:
|
||||
// const errorAlert = await screen.findByRole('alert');
|
||||
// expect(errorAlert).toHaveTextContent('An error occurred');
|
||||
});
|
||||
|
||||
test('should cleanup when unmounted during async operation', async () => {
|
||||
mockIsFeatureEnabled.mockImplementation(
|
||||
(flag: FeatureFlag) => flag === FeatureFlag.GlobalAsyncQueries,
|
||||
);
|
||||
|
||||
mockGetChartDataRequest.mockResolvedValue(createMockAsyncChartResponse());
|
||||
|
||||
// Mock a slow async operation
|
||||
let asyncResolve!: (value: unknown[]) => void;
|
||||
const asyncPromise = new Promise<unknown[]>(resolve => {
|
||||
asyncResolve = resolve;
|
||||
});
|
||||
mockWaitForAsyncData.mockReturnValue(asyncPromise);
|
||||
|
||||
const props = createDefaultProps(form);
|
||||
const { filterId } = props;
|
||||
|
||||
// Spy on console errors to catch React warnings about state updates after unmount
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
act(() => {
|
||||
form.setFieldsValue({
|
||||
filters: {
|
||||
[filterId]: {
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: id }, // numeric ID: 7
|
||||
column: 'name',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const { unmount } = render(<FiltersConfigForm {...props} />, {
|
||||
initialState: defaultState(),
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Wait for async operation to start
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest).toHaveBeenCalled();
|
||||
expect(mockWaitForAsyncData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Unmount before async completes
|
||||
unmount();
|
||||
|
||||
// Now resolve the async operation after unmount
|
||||
asyncResolve([
|
||||
{
|
||||
status: 'success',
|
||||
data: [{ name: 'Delayed', count: 1 }],
|
||||
applied_filters: [],
|
||||
},
|
||||
]);
|
||||
|
||||
// Wait a bit to ensure any state updates would have happened
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify no React warnings about state updates after unmount
|
||||
const stateUpdateWarnings = consoleErrorSpy.mock.calls.filter(
|
||||
call =>
|
||||
call[0]?.toString &&
|
||||
(call[0].toString().includes('unmounted component') ||
|
||||
call[0].toString().includes('memory leak')),
|
||||
);
|
||||
|
||||
// Component should properly cleanup - no warnings expected
|
||||
expect(stateUpdateWarnings.length).toBe(0);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should debounce rapid dataset changes', async () => {
|
||||
const filterId = 'NATIVE_FILTER-1';
|
||||
|
||||
// Set initial dataset
|
||||
form.setFieldsValue({
|
||||
filters: {
|
||||
[filterId]: {
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: id }, // numeric ID: 7
|
||||
column: 'name',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const props = createDefaultProps(form);
|
||||
const { unmount } = render(<FiltersConfigForm {...props} />, {
|
||||
initialState: defaultState(),
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Wait for initial refresh
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const initialCallCount = mockGetChartDataRequest.mock.calls.length;
|
||||
unmount();
|
||||
|
||||
// Rapidly change dataset multiple times
|
||||
const dataset2Id = id + 1; // 8
|
||||
const dataset3Id = id + 2; // 9
|
||||
|
||||
form.setFieldsValue({
|
||||
filters: {
|
||||
[filterId]: {
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: dataset2Id },
|
||||
column: 'name',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const props2 = createDefaultProps(form);
|
||||
const { unmount: unmount2 } = render(<FiltersConfigForm {...props2} />, {
|
||||
initialState: defaultState(),
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Immediately change again before first completes
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest.mock.calls.length).toBeGreaterThan(
|
||||
initialCallCount,
|
||||
);
|
||||
});
|
||||
|
||||
unmount2();
|
||||
|
||||
// Change to dataset 3
|
||||
form.setFieldsValue({
|
||||
filters: {
|
||||
[filterId]: {
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: dataset3Id },
|
||||
column: 'name',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const props3 = createDefaultProps(form);
|
||||
render(<FiltersConfigForm {...props3} />, {
|
||||
initialState: defaultState(),
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Verify final dataset is used
|
||||
await waitFor(() => {
|
||||
const lastCall =
|
||||
mockGetChartDataRequest.mock.calls[
|
||||
mockGetChartDataRequest.mock.calls.length - 1
|
||||
];
|
||||
expect(lastCall[0].formData.datasource).toBe(`${dataset3Id}__table`);
|
||||
});
|
||||
});
|
||||
|
||||
// Note: This test is skipped because properly testing the race condition requires
|
||||
// triggering two refreshHandler calls in quick succession, which is difficult in unit tests
|
||||
// since refreshHandler is internal and triggered by useEffect dependencies.
|
||||
// The component fix (latestRequestIdRef) is in place and will work in production.
|
||||
// Integration tests or manual testing should verify this behavior.
|
||||
test.skip('should handle out-of-order async responses', async () => {
|
||||
const filterId = 'NATIVE_FILTER-1';
|
||||
|
||||
// Mock two slow requests that we can control
|
||||
let resolveFirst!: (value: MockChartDataResponse) => void;
|
||||
let resolveSecond!: (value: MockChartDataResponse) => void;
|
||||
|
||||
const firstPromise = new Promise<MockChartDataResponse>(resolve => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
const secondPromise = new Promise<MockChartDataResponse>(resolve => {
|
||||
resolveSecond = resolve;
|
||||
});
|
||||
|
||||
// Queue up the two mocked responses
|
||||
mockGetChartDataRequest
|
||||
.mockReturnValueOnce(firstPromise)
|
||||
.mockReturnValueOnce(secondPromise);
|
||||
|
||||
// Initial render with dataset A
|
||||
form.setFieldsValue({
|
||||
filters: {
|
||||
[filterId]: {
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: id },
|
||||
column: 'name',
|
||||
isDataDirty: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const props = createDefaultProps(form);
|
||||
const { rerender } = render(<FiltersConfigForm {...props} />, {
|
||||
initialState: defaultState(),
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Wait for first request
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Trigger second refresh by changing dataset
|
||||
form.setFieldsValue({
|
||||
filters: {
|
||||
[filterId]: {
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: id + 1 },
|
||||
column: 'name',
|
||||
isDataDirty: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
rerender(<FiltersConfigForm {...props} />);
|
||||
|
||||
// Wait for second request
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Resolve SECOND request first (newer, should win)
|
||||
resolveSecond(createMockChartResponse([{ name: 'Dataset2', count: 999 }]));
|
||||
|
||||
await waitFor(() => {
|
||||
const formValues = form.getFieldValue('filters')?.[filterId];
|
||||
expect(formValues.defaultValueQueriesData).toBeDefined();
|
||||
expect(formValues.defaultValueQueriesData[0].data[0].name).toBe('Dataset2');
|
||||
});
|
||||
|
||||
// Resolve FIRST request late (older, should be ignored)
|
||||
resolveFirst(createMockChartResponse([{ name: 'Dataset1', count: 111 }]));
|
||||
|
||||
// Wait for late response to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify Dataset2 is still there (late Dataset1 was ignored)
|
||||
const finalFormValues = form.getFieldValue('filters')?.[filterId];
|
||||
expect(finalFormValues.defaultValueQueriesData[0].data[0].name).toBe(
|
||||
'Dataset2',
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle async query error during polling', async () => {
|
||||
mockIsFeatureEnabled.mockImplementation(
|
||||
(flag: FeatureFlag) => flag === FeatureFlag.GlobalAsyncQueries,
|
||||
);
|
||||
|
||||
mockGetChartDataRequest.mockResolvedValue(createMockAsyncChartResponse());
|
||||
|
||||
// Mock polling failure
|
||||
const pollingError = new Error('Async query failed');
|
||||
mockWaitForAsyncData.mockRejectedValue(pollingError);
|
||||
|
||||
const filterId = 'NATIVE_FILTER-1';
|
||||
|
||||
form.setFieldsValue({
|
||||
filters: {
|
||||
[filterId]: {
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: id },
|
||||
column: 'name',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const props = createDefaultProps(form);
|
||||
const { container } = render(<FiltersConfigForm {...props} />, {
|
||||
initialState: defaultState(),
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Wait for async flow to initiate
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest).toHaveBeenCalled();
|
||||
expect(mockWaitForAsyncData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for error handling
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Component should still be rendered (no crash)
|
||||
expect(container).toBeInTheDocument();
|
||||
|
||||
// defaultValueQueriesData should remain null after async error
|
||||
const formValues = form.getFieldValue('filters')?.[filterId];
|
||||
expect(formValues.defaultValueQueriesData).toBeNull();
|
||||
});
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
RefObject,
|
||||
memo,
|
||||
@@ -273,9 +274,19 @@ const FiltersConfigForm = (
|
||||
const [activeTabKey, setActiveTabKey] = useState<string>(
|
||||
FilterTabs.configuration.key,
|
||||
);
|
||||
const isMountedRef = useRef(true);
|
||||
const latestRequestIdRef = useRef(0);
|
||||
const dashboardId = useSelector<RootState, number>(
|
||||
state => state.dashboardInfo.id,
|
||||
);
|
||||
|
||||
// Cleanup on unmount to prevent state updates after unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
isMountedRef.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
const [undoFormValues, setUndoFormValues] = useState<Record<
|
||||
string,
|
||||
any
|
||||
@@ -427,6 +438,10 @@ const FiltersConfigForm = (
|
||||
});
|
||||
formData.extra_form_data = dependenciesDefaultValues;
|
||||
|
||||
// Track this request to ignore stale responses - use monotonic counter
|
||||
latestRequestIdRef.current += 1;
|
||||
const requestId = latestRequestIdRef.current;
|
||||
|
||||
setNativeFilterFieldValuesWrapper({
|
||||
defaultValueQueriesData: null,
|
||||
isDataDirty: false,
|
||||
@@ -436,6 +451,11 @@ const FiltersConfigForm = (
|
||||
force,
|
||||
})
|
||||
.then(({ response, json }) => {
|
||||
// Ignore stale responses from earlier requests
|
||||
if (!isMountedRef.current || requestId < latestRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) {
|
||||
// deal with getChartDataRequest transforming the response data
|
||||
const result = 'result' in json ? json.result[0] : json;
|
||||
@@ -447,14 +467,29 @@ const FiltersConfigForm = (
|
||||
} else if (response.status === 202) {
|
||||
waitForAsyncData(result)
|
||||
.then((asyncResult: ChartDataResponseResult[]) => {
|
||||
setNativeFilterFieldValuesWrapper({
|
||||
defaultValueQueriesData: asyncResult,
|
||||
});
|
||||
if (
|
||||
isMountedRef.current &&
|
||||
requestId >= latestRequestIdRef.current
|
||||
) {
|
||||
setNativeFilterFieldValuesWrapper({
|
||||
defaultValueQueriesData: asyncResult,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error: Response) => {
|
||||
getClientErrorObject(error).then(clientErrorObject => {
|
||||
setErrorWrapper(clientErrorObject);
|
||||
});
|
||||
if (
|
||||
isMountedRef.current &&
|
||||
requestId >= latestRequestIdRef.current
|
||||
) {
|
||||
getClientErrorObject(error).then(clientErrorObject => {
|
||||
if (
|
||||
isMountedRef.current &&
|
||||
requestId >= latestRequestIdRef.current
|
||||
) {
|
||||
setErrorWrapper(clientErrorObject);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
@@ -468,9 +503,16 @@ const FiltersConfigForm = (
|
||||
}
|
||||
})
|
||||
.catch((error: Response) => {
|
||||
getClientErrorObject(error).then(clientErrorObject => {
|
||||
setError(clientErrorObject);
|
||||
});
|
||||
if (isMountedRef.current && requestId >= latestRequestIdRef.current) {
|
||||
getClientErrorObject(error).then(clientErrorObject => {
|
||||
if (
|
||||
isMountedRef.current &&
|
||||
requestId >= latestRequestIdRef.current
|
||||
) {
|
||||
setError(clientErrorObject);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
[filterId, forceUpdate, form, formFilter, hasDataset, dependenciesText],
|
||||
@@ -648,17 +690,13 @@ const FiltersConfigForm = (
|
||||
useBackendFormUpdate(form, filterId);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasDataset && hasFilledDataset && hasDefaultValue && isDataDirty) {
|
||||
// Removed hasDefaultValue from condition - it was blocking refresh for filters without default values
|
||||
// isDataDirty provides the natural transition guard (false→true→false via hook dependency)
|
||||
// This allows dataset changes to trigger refresh regardless of default value preference
|
||||
if (hasDataset && hasFilledDataset && isDataDirty) {
|
||||
refreshHandler();
|
||||
}
|
||||
}, [
|
||||
hasDataset,
|
||||
hasFilledDataset,
|
||||
hasDefaultValue,
|
||||
isDataDirty,
|
||||
refreshHandler,
|
||||
showDataset,
|
||||
]);
|
||||
}, [hasDataset, hasFilledDataset, isDataDirty, refreshHandler, showDataset]);
|
||||
|
||||
const initiallyExcludedCharts = useMemo(() => {
|
||||
const excluded: number[] = [];
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import {
|
||||
RangeFilterPlugin,
|
||||
@@ -68,9 +69,7 @@ const defaultState = () => ({
|
||||
const noTemporalColumnsState = () => {
|
||||
const state = defaultState();
|
||||
return {
|
||||
charts: {
|
||||
...state.charts,
|
||||
},
|
||||
...state,
|
||||
datasources: {
|
||||
...state.datasources,
|
||||
[datasourceId]: {
|
||||
@@ -104,9 +103,20 @@ const bigIntChartDataState = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const datasetResult = (id: number) => ({
|
||||
const SECOND_DATASET_ID = id + 1;
|
||||
const SECOND_DATASET_NAME = 'users';
|
||||
const SECOND_DATASET_COLUMN = 'Column B';
|
||||
|
||||
const datasetResult = (
|
||||
datasetId: number,
|
||||
{
|
||||
tableName = 'birth_names',
|
||||
columnName = 'Column A',
|
||||
databaseName = 'main',
|
||||
}: { tableName?: string; columnName?: string; databaseName?: string } = {},
|
||||
) => ({
|
||||
description_columns: {},
|
||||
id,
|
||||
id: datasetId,
|
||||
label_columns: {
|
||||
columns: 'Columns',
|
||||
table_name: 'Table Name',
|
||||
@@ -115,18 +125,47 @@ const datasetResult = (id: number) => ({
|
||||
metrics: [],
|
||||
columns: [
|
||||
{
|
||||
column_name: 'Column A',
|
||||
id: 1,
|
||||
column_name: columnName,
|
||||
id: datasetId,
|
||||
},
|
||||
],
|
||||
table_name: 'birth_names',
|
||||
id,
|
||||
table_name: tableName,
|
||||
id: datasetId,
|
||||
database: {
|
||||
database_name: databaseName,
|
||||
},
|
||||
},
|
||||
show_columns: ['id', 'table_name'],
|
||||
});
|
||||
|
||||
fetchMock.get('glob:*/api/v1/dataset/1', datasetResult(1));
|
||||
fetchMock.get(`glob:*/api/v1/dataset/${id}`, datasetResult(id));
|
||||
fetchMock.get('glob:*/api/v1/dataset/1*', datasetResult(1));
|
||||
fetchMock.get(`glob:*/api/v1/dataset/${id}*`, datasetResult(id));
|
||||
fetchMock.get(
|
||||
`glob:*/api/v1/dataset/${SECOND_DATASET_ID}*`,
|
||||
datasetResult(SECOND_DATASET_ID, {
|
||||
tableName: SECOND_DATASET_NAME,
|
||||
columnName: SECOND_DATASET_COLUMN,
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock dataset list endpoint for AsyncSelect dropdown
|
||||
fetchMock.get('glob:*/api/v1/dataset/?*', {
|
||||
count: 2,
|
||||
result: [
|
||||
{
|
||||
id, // Use numeric id (7), not datasourceId ("7__table")
|
||||
table_name: 'birth_names',
|
||||
database: { database_name: 'main' },
|
||||
schema: 'public',
|
||||
},
|
||||
{
|
||||
id: SECOND_DATASET_ID,
|
||||
table_name: SECOND_DATASET_NAME,
|
||||
database: { database_name: 'main' },
|
||||
schema: 'analytics',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fetchMock.post('glob:*/api/v1/chart/data', {
|
||||
result: [
|
||||
@@ -147,7 +186,6 @@ const FILTER_NAME_REGEX = /^filter name$/i;
|
||||
const DATASET_REGEX = /^dataset$/i;
|
||||
const COLUMN_REGEX = /^column$/i;
|
||||
const VALUE_REGEX = /^value$/i;
|
||||
const NUMERICAL_RANGE_REGEX = /^numerical range$/i;
|
||||
const TIME_RANGE_REGEX = /^time range$/i;
|
||||
const TIME_COLUMN_REGEX = /^time column$/i;
|
||||
const TIME_GRAIN_REGEX = /^time grain$/i;
|
||||
@@ -169,6 +207,107 @@ const PRE_FILTER_REQUIRED_REGEX = /^pre-filter is required$/i;
|
||||
const FILL_REQUIRED_FIELDS_REGEX = /fill all required fields to enable/;
|
||||
const TIME_RANGE_PREFILTER_REGEX = /^time range$/i;
|
||||
|
||||
const getOpenDropdown = () =>
|
||||
document.querySelector(
|
||||
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)',
|
||||
) as HTMLElement | null;
|
||||
|
||||
const findDropdownOption = (text: string) =>
|
||||
waitFor(() => {
|
||||
const dropdown = getOpenDropdown();
|
||||
if (!dropdown) {
|
||||
throw new Error('Dropdown not visible');
|
||||
}
|
||||
return within(dropdown).getByText(text);
|
||||
});
|
||||
|
||||
const getSelectTrigger = (label: string) => {
|
||||
const byAria = screen.queryByLabelText(label) as HTMLElement | null;
|
||||
if (byAria) {
|
||||
return byAria;
|
||||
}
|
||||
const byDataTest = document.querySelector(
|
||||
`[data-test="${label}"]`,
|
||||
) as HTMLElement | null;
|
||||
if (byDataTest) {
|
||||
return byDataTest;
|
||||
}
|
||||
const labelNode = screen.queryByText(new RegExp(`^${label}$`, 'i'));
|
||||
if (labelNode) {
|
||||
const container =
|
||||
labelNode.closest('.ant-form-item') ??
|
||||
labelNode.parentElement?.parentElement ??
|
||||
labelNode.parentElement ??
|
||||
undefined;
|
||||
const select = container?.querySelector(
|
||||
'.ant-select-selector',
|
||||
) as HTMLElement | null;
|
||||
if (select) {
|
||||
return select;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const selectOption = async (label: string, optionText: string) => {
|
||||
const trigger = getSelectTrigger(label);
|
||||
if (!trigger) {
|
||||
const availableDataTests = Array.from(
|
||||
document.querySelectorAll('[data-test]'),
|
||||
).map(node => (node as HTMLElement).getAttribute('data-test'));
|
||||
const matchingLabels = screen
|
||||
.queryAllByText(new RegExp(label, 'i'))
|
||||
.map(node => node.textContent);
|
||||
throw new Error(
|
||||
`Unable to find select trigger for ${label}. Matched label texts: [${matchingLabels.join(
|
||||
', ',
|
||||
)}]. Available data-test attributes: ${availableDataTests.join(', ')}`,
|
||||
);
|
||||
}
|
||||
await userEvent.click(trigger);
|
||||
const option = await findDropdownOption(optionText);
|
||||
await userEvent.click(option);
|
||||
};
|
||||
|
||||
const selectDatasetOption = async (optionText: string) =>
|
||||
selectOption('Dataset', optionText);
|
||||
|
||||
const selectColumnOption = async (optionText: string) =>
|
||||
selectOption('Column', optionText);
|
||||
|
||||
// Helper to wait for all loading states to complete
|
||||
const waitForFormStability = async (timeout = 5000) => {
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.queryByLabelText('Loading')).not.toBeInTheDocument();
|
||||
},
|
||||
{ timeout },
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to open Filter Settings accordion panel
|
||||
const openFilterSettings = async () => {
|
||||
const settingsHeader = screen.getByText(FILTER_SETTINGS_REGEX);
|
||||
await userEvent.click(settingsHeader);
|
||||
// Wait for panel to expand and content to be visible
|
||||
await waitFor(() => {
|
||||
// Check for an element that should be in the expanded panel
|
||||
expect(getCheckbox(MULTIPLE_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to select a filter type from the dropdown
|
||||
const selectFilterType = async (filterTypeName: string) => {
|
||||
const filterTypeButton = screen.getByText(VALUE_REGEX);
|
||||
await userEvent.click(filterTypeButton);
|
||||
|
||||
const option = await screen.findByText(filterTypeName);
|
||||
await userEvent.click(option);
|
||||
|
||||
// Wait for form to re-render with new filter type
|
||||
await waitForFormStability();
|
||||
};
|
||||
|
||||
const props: FiltersConfigModalProps = {
|
||||
isOpen: true,
|
||||
createNewOnOpen: true,
|
||||
@@ -184,6 +323,9 @@ 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,
|
||||
@@ -223,10 +365,7 @@ test('renders a value filter type', () => {
|
||||
test('renders a numerical range filter type', async () => {
|
||||
defaultRender();
|
||||
|
||||
await userEvent.click(screen.getByText(VALUE_REGEX));
|
||||
|
||||
const numericalRangeOption = await screen.findByText(NUMERICAL_RANGE_REGEX);
|
||||
await userEvent.click(numericalRangeOption);
|
||||
await selectFilterType('Numerical range');
|
||||
|
||||
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
|
||||
@@ -234,6 +373,9 @@ test('renders a numerical range filter type', async () => {
|
||||
expect(screen.getByText(COLUMN_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(FILTER_REQUIRED_REGEX)).toBeInTheDocument();
|
||||
|
||||
// Open Filter Settings accordion to access checkboxes
|
||||
await openFilterSettings();
|
||||
|
||||
expect(getCheckbox(DEFAULT_VALUE_REGEX)).not.toBeChecked();
|
||||
expect(getCheckbox(PRE_FILTER_REGEX)).not.toBeChecked();
|
||||
|
||||
@@ -248,55 +390,57 @@ test('renders a numerical range filter type', async () => {
|
||||
test('renders a time range filter type', async () => {
|
||||
defaultRender();
|
||||
|
||||
await userEvent.click(screen.getByText(VALUE_REGEX));
|
||||
|
||||
const timeRangeOption = await screen.findByText(TIME_RANGE_REGEX);
|
||||
await userEvent.click(timeRangeOption);
|
||||
await selectFilterType('Time range');
|
||||
|
||||
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
|
||||
expect(screen.queryByText(DATASET_REGEX)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(COLUMN_REGEX)).not.toBeInTheDocument();
|
||||
|
||||
// Open Filter Settings accordion to access checkboxes
|
||||
await openFilterSettings();
|
||||
|
||||
expect(getCheckbox(DEFAULT_VALUE_REGEX)).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('renders a time column filter type', async () => {
|
||||
defaultRender();
|
||||
|
||||
await userEvent.click(screen.getByText(VALUE_REGEX));
|
||||
|
||||
const timeColumnOption = await screen.findByText(TIME_COLUMN_REGEX);
|
||||
await userEvent.click(timeColumnOption);
|
||||
await selectFilterType('Time column');
|
||||
|
||||
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(DATASET_REGEX)).toBeInTheDocument();
|
||||
expect(screen.queryByText(COLUMN_REGEX)).not.toBeInTheDocument();
|
||||
|
||||
// Open Filter Settings accordion to access checkboxes
|
||||
await openFilterSettings();
|
||||
|
||||
expect(getCheckbox(DEFAULT_VALUE_REGEX)).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('renders a time grain filter type', async () => {
|
||||
defaultRender();
|
||||
|
||||
await userEvent.click(screen.getByText(VALUE_REGEX));
|
||||
|
||||
const timeGrainOption = await screen.findByText(TIME_GRAIN_REGEX);
|
||||
await userEvent.click(timeGrainOption);
|
||||
await selectFilterType('Time grain');
|
||||
|
||||
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(DATASET_REGEX)).toBeInTheDocument();
|
||||
expect(screen.queryByText(COLUMN_REGEX)).not.toBeInTheDocument();
|
||||
|
||||
// Open Filter Settings accordion to access checkboxes
|
||||
await openFilterSettings();
|
||||
|
||||
expect(getCheckbox(DEFAULT_VALUE_REGEX)).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('render time filter types as disabled if there are no temporal columns in the dataset', async () => {
|
||||
defaultRender(noTemporalColumnsState());
|
||||
|
||||
await userEvent.click(screen.getByText(VALUE_REGEX));
|
||||
// Open filter type dropdown
|
||||
const filterTypeButton = screen.getByText(VALUE_REGEX);
|
||||
await userEvent.click(filterTypeButton);
|
||||
|
||||
const timeRange = await screen.findByText(TIME_RANGE_REGEX);
|
||||
const timeGrain = await screen.findByText(TIME_GRAIN_REGEX);
|
||||
@@ -311,12 +455,7 @@ test('render time filter types as disabled if there are no temporal columns in t
|
||||
test('validates the name', async () => {
|
||||
defaultRender();
|
||||
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
await waitFor(
|
||||
async () => {
|
||||
expect(await screen.findByText(NAME_REQUIRED_REGEX)).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
expect(await screen.findByText(NAME_REQUIRED_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('validates the column', async () => {
|
||||
@@ -325,17 +464,34 @@ test('validates the column', async () => {
|
||||
expect(await screen.findByText(COLUMN_REQUIRED_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
test.skip('validates the default value', async () => {
|
||||
defaultRender(noTemporalColumnsState());
|
||||
expect(await screen.findByText('birth_names')).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByRole('combobox'), `Column A{Enter}`);
|
||||
test('validates the default value', async () => {
|
||||
defaultRender();
|
||||
|
||||
// Wait for form to render and stabilize
|
||||
expect(await screen.findByText(DATASET_REGEX)).toBeInTheDocument();
|
||||
await waitForFormStability();
|
||||
|
||||
// Select dataset and column
|
||||
await selectDatasetOption('birth_names');
|
||||
await waitForFormStability();
|
||||
|
||||
await selectColumnOption('Column A');
|
||||
await waitForFormStability();
|
||||
|
||||
// Open Filter Settings to access Default Value checkbox
|
||||
await openFilterSettings();
|
||||
|
||||
// Enable "Filter has default value" checkbox
|
||||
await userEvent.click(getCheckbox(DEFAULT_VALUE_REGEX));
|
||||
|
||||
// Wait for "fill required fields" message to clear
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(FILL_REQUIRED_FIELDS_REGEX),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should show "default value is required" validation error
|
||||
expect(
|
||||
await screen.findByText(DEFAULT_VALUE_REQUIRED_REGEX),
|
||||
).toBeInTheDocument();
|
||||
@@ -346,44 +502,50 @@ test('validates the pre-filter value', async () => {
|
||||
try {
|
||||
defaultRender();
|
||||
|
||||
await userEvent.click(screen.getByText(FILTER_SETTINGS_REGEX));
|
||||
await userEvent.click(getCheckbox(PRE_FILTER_REGEX));
|
||||
userEvent.click(screen.getByText(FILTER_SETTINGS_REGEX));
|
||||
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(
|
||||
() => {
|
||||
const errorMessages = screen.queryAllByText(PRE_FILTER_REQUIRED_REGEX);
|
||||
expect(errorMessages.length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(PRE_FILTER_REQUIRED_REGEX)).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
}, 50000); // Slow-running test, increase timeout to 50 seconds.
|
||||
|
||||
// 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 () => {
|
||||
test("doesn't render time range pre-filter if there are no temporal columns in datasource", async () => {
|
||||
defaultRender(noTemporalColumnsState());
|
||||
await userEvent.click(screen.getByText(DATASET_REGEX));
|
||||
await waitFor(async () => {
|
||||
expect(screen.queryByLabelText('Loading')).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('birth_names'));
|
||||
});
|
||||
await userEvent.click(screen.getByText(FILTER_SETTINGS_REGEX));
|
||||
|
||||
// Wait for form to render
|
||||
expect(await screen.findByText(DATASET_REGEX)).toBeInTheDocument();
|
||||
await waitForFormStability();
|
||||
|
||||
// Select dataset that has no temporal columns
|
||||
await selectDatasetOption('birth_names');
|
||||
await waitForFormStability();
|
||||
|
||||
// Open Filter Settings accordion
|
||||
await openFilterSettings();
|
||||
|
||||
// Enable pre-filter
|
||||
await userEvent.click(getCheckbox(PRE_FILTER_REGEX));
|
||||
await waitFor(() =>
|
||||
|
||||
// Wait for pre-filter options to potentially render
|
||||
await waitFor(() => {
|
||||
// Time range pre-filter should NOT be available for datasets without temporal columns
|
||||
expect(
|
||||
screen.queryByText(TIME_RANGE_PREFILTER_REGEX),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('filters are draggable', async () => {
|
||||
@@ -443,9 +605,9 @@ test('deletes a filter', async () => {
|
||||
const removeButtons = screen.getAllByRole('button', {
|
||||
name: 'delete',
|
||||
});
|
||||
await userEvent.click(removeButtons[2]);
|
||||
userEvent.click(removeButtons[2]);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
@@ -480,8 +642,8 @@ test('deletes a filter including dependencies', async () => {
|
||||
const removeButtons = screen.getAllByRole('button', {
|
||||
name: 'delete',
|
||||
});
|
||||
await userEvent.click(removeButtons[1]);
|
||||
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
userEvent.click(removeButtons[1]);
|
||||
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -529,7 +691,7 @@ test('switches the order between two filters', async () => {
|
||||
|
||||
fireEvent.dragEnd(draggableFilters[0]);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
@@ -572,14 +734,14 @@ test('rearranges three filters and deletes one of them', async () => {
|
||||
const deleteButtons = screen.getAllByRole('button', {
|
||||
name: 'delete',
|
||||
});
|
||||
await userEvent.click(deleteButtons[1]);
|
||||
userEvent.click(deleteButtons[1]);
|
||||
|
||||
fireEvent.dragStart(draggableFilters[0]);
|
||||
fireEvent.dragOver(draggableFilters[2]);
|
||||
fireEvent.drop(draggableFilters[2]);
|
||||
fireEvent.dragEnd(draggableFilters[0]);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
@@ -598,51 +760,47 @@ test('rearranges three filters and deletes one of them', async () => {
|
||||
|
||||
test('modifies the name of a filter', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const nativeFilterState = [
|
||||
buildNativeFilter('NATIVE_FILTER-1', 'state', []),
|
||||
buildNativeFilter('NATIVE_FILTER-2', 'country', []),
|
||||
];
|
||||
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,
|
||||
});
|
||||
|
||||
await userEvent.clear(filterNameInput);
|
||||
await userEvent.type(filterNameInput, 'New Filter Name');
|
||||
userEvent.clear(filterNameInput);
|
||||
userEvent.type(filterNameInput, 'New Filter Name');
|
||||
|
||||
jest.runAllTimers();
|
||||
jest.runAllTimers();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modified: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'New Filter Name' }),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modified: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'New Filter Name' }),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('renders a filter with a chart containing BigInt values', async () => {
|
||||
|
||||
@@ -39,260 +39,90 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
};
|
||||
// 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',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(divider)).toBe(true);
|
||||
});
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(divider)).toBe(true);
|
||||
});
|
||||
|
||||
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[] = [
|
||||
{
|
||||
test('should return true for filters with charts in active tabs', () => {
|
||||
const filter: 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',
|
||||
},
|
||||
];
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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',
|
||||
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: 'Filter that should be in scope',
|
||||
},
|
||||
{
|
||||
id: 'filter_out_scope',
|
||||
name: 'Out of Scope Filter',
|
||||
description: 'Sample filter description',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(filter)).toBe(true);
|
||||
});
|
||||
|
||||
test('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: 2 }],
|
||||
description: 'Filter that should be out of scope',
|
||||
},
|
||||
];
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 3 }],
|
||||
description: 'Sample filter description',
|
||||
};
|
||||
|
||||
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' }),
|
||||
);
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(filter)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
};
|
||||
// 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',
|
||||
},
|
||||
];
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,7 +153,7 @@ export const useDownloadScreenshot = (
|
||||
anchor,
|
||||
activeTabs,
|
||||
dataMask,
|
||||
urlParams: getDashboardUrlParams(),
|
||||
urlParams: getDashboardUrlParams(['edit']),
|
||||
},
|
||||
})
|
||||
.then(({ json }) => {
|
||||
|
||||
@@ -1,162 +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 { 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,16 +69,7 @@ function handleFilterChangesComplete(
|
||||
) {
|
||||
const modifiedFilters = { ...state.filters };
|
||||
filters.forEach(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,
|
||||
};
|
||||
}
|
||||
modifiedFilters[filter.id] = filter;
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -153,6 +153,12 @@ 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);
|
||||
@@ -187,7 +193,7 @@ test('"Close" button should call "onHide"', async () => {
|
||||
expect(props.onHide).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onHide).toHaveBeenCalledTimes(1);
|
||||
@@ -248,7 +254,7 @@ test('"Cancel" button should call "onHide"', async () => {
|
||||
expect(props.onHide).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onHide).toHaveBeenCalledTimes(1);
|
||||
@@ -256,7 +262,7 @@ test('"Cancel" button should call "onHide"', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('"Save" button should call "onSave" and "onHide"', async () => {
|
||||
test('"Save" button should call only "onSave"', async () => {
|
||||
const props = createProps();
|
||||
renderModal(props);
|
||||
await waitFor(() => {
|
||||
@@ -265,7 +271,7 @@ test('"Save" button should call "onSave" and "onHide"', async () => {
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledTimes(1);
|
||||
@@ -287,7 +293,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) {
|
||||
await userEvent.click(advancedPanel);
|
||||
userEvent.click(advancedPanel);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -306,11 +312,11 @@ test('"Name" should not be empty', async () => {
|
||||
|
||||
const name = screen.getByRole('textbox', { name: 'Name' });
|
||||
|
||||
await userEvent.clear(name);
|
||||
userEvent.clear(name);
|
||||
|
||||
expect(name).toHaveValue('');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledTimes(0);
|
||||
@@ -323,12 +329,12 @@ test('"Name" should not be empty when saved', async () => {
|
||||
|
||||
const name = screen.getByRole('textbox', { name: 'Name' });
|
||||
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, 'Test chart new name');
|
||||
userEvent.clear(name);
|
||||
userEvent.type(name, 'Test chart new name');
|
||||
|
||||
expect(name).toHaveValue('Test chart new name');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledTimes(1);
|
||||
@@ -345,17 +351,19 @@ 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) {
|
||||
await userEvent.click(configPanel);
|
||||
userEvent.click(configPanel);
|
||||
}
|
||||
|
||||
const cacheTimeout = await screen.findByRole('textbox', {
|
||||
name: 'Cache timeout',
|
||||
});
|
||||
await userEvent.clear(cacheTimeout);
|
||||
await userEvent.type(cacheTimeout, '1000');
|
||||
expect(cacheTimeout).toHaveValue('1000');
|
||||
await waitFor(() => {
|
||||
const cacheTimeout = screen.getByRole('textbox', { name: 'Cache timeout' });
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
userEvent.clear(cacheTimeout);
|
||||
userEvent.type(cacheTimeout, '1000');
|
||||
|
||||
expect(cacheTimeout).toHaveValue('1000');
|
||||
});
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledTimes(1);
|
||||
@@ -378,12 +386,12 @@ test('"Description" should not be empty when saved', async () => {
|
||||
const textboxes = screen.getAllByRole('textbox');
|
||||
const description = textboxes[1]; // Description is the textarea
|
||||
|
||||
await userEvent.clear(description);
|
||||
await userEvent.type(description, 'Test description');
|
||||
userEvent.clear(description);
|
||||
userEvent.type(description, 'Test description');
|
||||
|
||||
expect(description).toHaveValue('Test description');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledTimes(1);
|
||||
@@ -400,17 +408,19 @@ 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) {
|
||||
await userEvent.click(advancedPanel);
|
||||
userEvent.click(advancedPanel);
|
||||
}
|
||||
|
||||
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');
|
||||
await waitFor(() => {
|
||||
const certifiedBy = screen.getByRole('textbox', { name: 'Certified by' });
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
userEvent.clear(certifiedBy);
|
||||
userEvent.type(certifiedBy, 'Test certified by');
|
||||
|
||||
expect(certifiedBy).toHaveValue('Test certified by');
|
||||
});
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledTimes(1);
|
||||
@@ -427,17 +437,21 @@ 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) {
|
||||
await userEvent.click(advancedPanel);
|
||||
userEvent.click(advancedPanel);
|
||||
}
|
||||
|
||||
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');
|
||||
await waitFor(() => {
|
||||
const certificationDetails = screen.getByRole('textbox', {
|
||||
name: 'Certification details',
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
userEvent.clear(certificationDetails);
|
||||
userEvent.type(certificationDetails, 'Test certification details');
|
||||
|
||||
expect(certificationDetails).toHaveValue('Test certification details');
|
||||
});
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledTimes(1);
|
||||
@@ -460,14 +474,18 @@ test('Should display only custom tags when tagging system is enabled', async ()
|
||||
const props = createProps();
|
||||
renderModal(props);
|
||||
|
||||
expect(await screen.findByText('Tags')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole('combobox', { name: 'Tags' }),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText('Tags')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole('combobox', { name: 'Tags' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(await screen.findByText('my test tag')).toBeInTheDocument();
|
||||
expect(screen.queryByText('type:chart')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('owner:1')).not.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();
|
||||
});
|
||||
|
||||
mockIsFeatureEnabled.mockRestore();
|
||||
});
|
||||
|
||||
@@ -1,108 +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 { 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, Fragment, useEffect, useState } from 'react';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
ensureIsArray,
|
||||
@@ -24,9 +24,8 @@ import {
|
||||
getClientErrorObject,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { styled, Alert } from '@apache-superset/core/ui';
|
||||
import { styled } 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';
|
||||
|
||||
@@ -35,9 +34,8 @@ interface Props {
|
||||
}
|
||||
|
||||
type Result = {
|
||||
query?: string;
|
||||
language: SupportedLanguage;
|
||||
error?: string;
|
||||
query: string;
|
||||
language: string;
|
||||
};
|
||||
|
||||
const ViewQueryModalContainer = styled.div`
|
||||
@@ -89,21 +87,16 @@ const ViewQueryModal: FC<Props> = ({ latestQueryFormData }) => {
|
||||
|
||||
return (
|
||||
<ViewQueryModalContainer>
|
||||
{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>
|
||||
))}
|
||||
{result.map((item, index) =>
|
||||
item.query ? (
|
||||
<ViewQuery
|
||||
key={`query-${index}`}
|
||||
datasource={latestQueryFormData.datasource}
|
||||
sql={item.query}
|
||||
language="sql"
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</ViewQueryModalContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -379,300 +379,4 @@ 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,20 +317,13 @@ 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.sortMetric],
|
||||
[formData.sortAscending],
|
||||
);
|
||||
|
||||
// Use effect for initialisation for filter plugin
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,190 +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 {
|
||||
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,59 +27,3 @@ 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,12 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
isUrlExternal,
|
||||
parseUrl,
|
||||
toQueryString,
|
||||
getDashboardUrlParams,
|
||||
} from './urlUtils';
|
||||
import { isUrlExternal, parseUrl, toQueryString } from './urlUtils';
|
||||
|
||||
test('isUrlExternal', () => {
|
||||
expect(isUrlExternal('http://google.com')).toBeTruthy();
|
||||
@@ -100,48 +95,3 @@ 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,7 +35,6 @@ 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');
|
||||
@@ -182,11 +181,6 @@ 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(
|
||||
|
||||
@@ -171,7 +171,7 @@ class ChartDataRestApi(ChartRestApi):
|
||||
and query_context.result_format == ChartDataResultFormat.JSON
|
||||
and query_context.result_type == ChartDataResultType.FULL
|
||||
):
|
||||
return self._run_async(json_body, command, add_extra_log_payload)
|
||||
return self._run_async(json_body, command)
|
||||
|
||||
try:
|
||||
form_data = json.loads(chart.params)
|
||||
@@ -265,7 +265,7 @@ class ChartDataRestApi(ChartRestApi):
|
||||
and query_context.result_format == ChartDataResultFormat.JSON
|
||||
and query_context.result_type == ChartDataResultType.FULL
|
||||
):
|
||||
return self._run_async(json_body, command, add_extra_log_payload)
|
||||
return self._run_async(json_body, command)
|
||||
|
||||
form_data = json_body.get("form_data")
|
||||
return self._get_data_response(
|
||||
@@ -334,10 +334,7 @@ class ChartDataRestApi(ChartRestApi):
|
||||
return self._get_data_response(command, True)
|
||||
|
||||
def _run_async(
|
||||
self,
|
||||
form_data: dict[str, Any],
|
||||
command: ChartDataCommand,
|
||||
add_extra_log_payload: Callable[..., None] | None = None,
|
||||
self, form_data: dict[str, Any], command: ChartDataCommand
|
||||
) -> Response:
|
||||
"""
|
||||
Execute command as an async query.
|
||||
@@ -346,10 +343,6 @@ class ChartDataRestApi(ChartRestApi):
|
||||
with contextlib.suppress(ChartDataCacheLoadError):
|
||||
result = command.run(force_cached=True)
|
||||
if result is not None:
|
||||
# Log is_cached if extra payload callback is provided.
|
||||
# This indicates no async job was triggered - data was already cached
|
||||
# and a synchronous response is being returned immediately.
|
||||
self._log_is_cached(result, add_extra_log_payload)
|
||||
return self._send_chart_response(result)
|
||||
# Otherwise, kick off a background job to run the chart query.
|
||||
# Clients will either poll or be notified of query completion,
|
||||
@@ -431,25 +424,6 @@ class ChartDataRestApi(ChartRestApi):
|
||||
|
||||
return self.response_400(message=f"Unsupported result_format: {result_format}")
|
||||
|
||||
def _log_is_cached(
|
||||
self,
|
||||
result: dict[str, Any],
|
||||
add_extra_log_payload: Callable[..., None] | None,
|
||||
) -> None:
|
||||
"""
|
||||
Log is_cached values from query results to event logger.
|
||||
|
||||
Extracts is_cached from each query in the result and logs it.
|
||||
If there's a single query, logs the boolean value directly.
|
||||
If multiple queries, logs as a list.
|
||||
"""
|
||||
if add_extra_log_payload and result and "queries" in result:
|
||||
is_cached_values = [query.get("is_cached") for query in result["queries"]]
|
||||
if len(is_cached_values) == 1:
|
||||
add_extra_log_payload(is_cached=is_cached_values[0])
|
||||
elif is_cached_values:
|
||||
add_extra_log_payload(is_cached=is_cached_values)
|
||||
|
||||
@event_logger.log_this
|
||||
def _get_data_response(
|
||||
self,
|
||||
@@ -468,7 +442,12 @@ class ChartDataRestApi(ChartRestApi):
|
||||
return self.response_400(message=exc.message)
|
||||
|
||||
# Log is_cached if extra payload callback is provided
|
||||
self._log_is_cached(result, add_extra_log_payload)
|
||||
if add_extra_log_payload and result and "queries" in result:
|
||||
is_cached_values = [query.get("is_cached") for query in result["queries"]]
|
||||
if len(is_cached_values) == 1:
|
||||
add_extra_log_payload(is_cached=is_cached_values[0])
|
||||
elif is_cached_values:
|
||||
add_extra_log_payload(is_cached=is_cached_values)
|
||||
|
||||
return self._send_chart_response(result, form_data, datasource)
|
||||
|
||||
|
||||
@@ -1482,12 +1482,9 @@ class ChartDataResponseResult(Schema):
|
||||
allow_none=None,
|
||||
)
|
||||
query = fields.String(
|
||||
metadata={
|
||||
"description": "The executed query statement. May be absent when "
|
||||
"validation errors occur."
|
||||
},
|
||||
required=False,
|
||||
allow_none=True,
|
||||
metadata={"description": "The executed query statement"},
|
||||
required=True,
|
||||
allow_none=False,
|
||||
)
|
||||
status = fields.String(
|
||||
metadata={"description": "Status of the query"},
|
||||
|
||||
@@ -24,7 +24,6 @@ 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
|
||||
|
||||
@@ -49,13 +48,8 @@ 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")
|
||||
and self._query_context.result_type != ChartDataResultType.QUERY
|
||||
):
|
||||
if query.get("error"):
|
||||
raise ChartDataQueryFailedError(
|
||||
_("Error: %(error)s", error=query["error"])
|
||||
)
|
||||
|
||||
@@ -177,35 +177,19 @@ class BaseReportState:
|
||||
"""
|
||||
Creates a Report execution log, uses the current computed last_value for Alerts
|
||||
"""
|
||||
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
|
||||
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
|
||||
|
||||
def _get_url(
|
||||
self,
|
||||
@@ -759,7 +743,7 @@ class ReportNotTriggeredErrorState(BaseReportState):
|
||||
current_states = [ReportState.NOOP, ReportState.ERROR]
|
||||
initial = True
|
||||
|
||||
def next(self) -> None: # noqa: C901
|
||||
def next(self) -> None:
|
||||
self.update_report_schedule_and_log(ReportState.WORKING)
|
||||
try:
|
||||
# If it's an alert check if the alert is triggered
|
||||
@@ -774,21 +758,9 @@ class ReportNotTriggeredErrorState(BaseReportState):
|
||||
if isinstance(first_ex, SupersetErrorsException):
|
||||
error_message = ";".join([error.message for error in first_ex.errors])
|
||||
|
||||
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
|
||||
self.update_report_schedule_and_log(
|
||||
ReportState.ERROR, error_message=error_message
|
||||
)
|
||||
|
||||
# TODO (dpgaspar) convert this logic to a new state eg: ERROR_ON_GRACE
|
||||
if not self.is_in_error_grace_period():
|
||||
@@ -804,26 +776,12 @@ 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:
|
||||
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,
|
||||
)
|
||||
self.update_report_schedule_and_log(
|
||||
ReportState.ERROR, error_message=second_error_message
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@@ -893,22 +851,9 @@ class ReportSuccessState(BaseReportState):
|
||||
self.send()
|
||||
self.update_report_schedule_and_log(ReportState.SUCCESS)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
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
|
||||
self.update_report_schedule_and_log(
|
||||
ReportState.ERROR, error_message=str(ex)
|
||||
)
|
||||
|
||||
|
||||
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, SupersetParseError
|
||||
from superset.exceptions import QueryObjectValidationError
|
||||
from superset.utils.core import (
|
||||
extract_column_dtype,
|
||||
extract_dataframe_dtypes,
|
||||
@@ -86,15 +86,7 @@ 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
|
||||
|
||||
|
||||
|
||||
@@ -1502,14 +1502,8 @@ class SqlaTable(
|
||||
"""
|
||||
label = utils.get_column_name(col)
|
||||
try:
|
||||
sql_expression = col["sqlExpression"]
|
||||
|
||||
# For column references, conditionally quote identifiers that need it
|
||||
if col.get("isColumnReference"):
|
||||
sql_expression = self.database.quote_identifier(sql_expression)
|
||||
|
||||
expression = self._process_select_expression(
|
||||
expression=sql_expression,
|
||||
expression=col["sqlExpression"],
|
||||
database_id=self.database_id,
|
||||
engine=self.database.backend,
|
||||
schema=self.schema,
|
||||
|
||||
@@ -67,8 +67,7 @@ Explore & Analysis:
|
||||
- generate_explore_link: Create pre-configured explore URL with dataset/metrics/filters
|
||||
|
||||
System Information:
|
||||
- get_instance_info: Get instance-wide statistics and metadata
|
||||
- health_check: Simple health check tool (takes NO parameters, call without arguments)
|
||||
- get_superset_instance_info: Get instance-wide statistics and metadata
|
||||
|
||||
Available Resources:
|
||||
- superset://instance/metadata: Access instance configuration and metadata
|
||||
@@ -119,7 +118,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_instance_info
|
||||
If you are unsure which tool to use, start with get_superset_instance_info
|
||||
or use the superset_quickstart prompt for an interactive guide.
|
||||
"""
|
||||
|
||||
@@ -284,7 +283,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_instance_info,
|
||||
get_superset_instance_info,
|
||||
health_check,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,18 +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.
|
||||
|
||||
"""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_instance_info` - Overview of your Superset instance
|
||||
- `get_superset_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.system_utils import (
|
||||
from superset.mcp_service.system.tool.get_superset_instance_info 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_instance_info for basic stats",
|
||||
"Use get_superset_instance_info for basic stats",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -45,13 +45,14 @@ class HealthCheckResponse(BaseModel):
|
||||
|
||||
class GetSupersetInstanceInfoRequest(BaseModel):
|
||||
"""
|
||||
Request schema for get_instance_info tool.
|
||||
Request schema for get_superset_instance_info tool.
|
||||
|
||||
Currently has no parameters but provides consistent API for future extensibility.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra="forbid",
|
||||
str_strip_whitespace=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
|
||||
"""System tools for MCP service."""
|
||||
|
||||
from .get_instance_info import get_instance_info
|
||||
from .get_superset_instance_info import get_superset_instance_info
|
||||
from .health_check import health_check
|
||||
|
||||
__all__ = [
|
||||
"health_check",
|
||||
"get_instance_info",
|
||||
"get_superset_instance_info",
|
||||
]
|
||||
|
||||
@@ -1,106 +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.
|
||||
|
||||
"""
|
||||
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
|
||||
@@ -16,22 +16,30 @@
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
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.
|
||||
Get Superset instance high-level information FastMCP tool using configurable
|
||||
InstanceInfoCore for flexible, extensible metrics calculation.
|
||||
"""
|
||||
|
||||
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],
|
||||
@@ -194,3 +202,67 @@ 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
|
||||
@@ -21,12 +21,9 @@ 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__)
|
||||
|
||||
@@ -37,44 +34,18 @@ 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 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
|
||||
HealthCheckResponse: Health status and system information
|
||||
"""
|
||||
# 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=service_name,
|
||||
version=version,
|
||||
service="Superset MCP Service",
|
||||
version="1.0.0",
|
||||
python_version=platform.python_version(),
|
||||
platform=platform.system(),
|
||||
)
|
||||
@@ -85,12 +56,11 @@ 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
|
||||
response = HealthCheckResponse(
|
||||
return HealthCheckResponse(
|
||||
status="error",
|
||||
timestamp=datetime.datetime.now().isoformat(),
|
||||
service=service_name,
|
||||
version="unknown",
|
||||
service="Superset MCP Service",
|
||||
version="1.0.0",
|
||||
python_version=platform.python_version(),
|
||||
platform=platform.system(),
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -337,32 +337,16 @@ class SqlLabRestApi(BaseSupersetApi):
|
||||
params = kwargs["rison"]
|
||||
key = params.get("key")
|
||||
rows = params.get("rows")
|
||||
|
||||
try:
|
||||
result = SqlExecutionResultsCommand(key=key, rows=rows).run()
|
||||
except Exception as ex:
|
||||
logger.exception("Error fetching query results for key=%s", key)
|
||||
return self.response_500(message=str(ex))
|
||||
result = SqlExecutionResultsCommand(key=key, rows=rows).run()
|
||||
|
||||
# Using pessimistic json serialization since some database drivers can return
|
||||
# unserializeable types at times
|
||||
try:
|
||||
payload = json.dumps(
|
||||
result,
|
||||
default=json.pessimistic_json_iso_dttm_ser,
|
||||
ignore_nan=True,
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.exception("Error serializing query results for key=%s", key)
|
||||
return self.response_500(message="Unable to serialize query results")
|
||||
|
||||
# Use json_success with explicit Content-Type to ensure Flask 2.3+ correctly
|
||||
# handles the response and doesn't trigger HTTP 406 errors due to content
|
||||
# negotiation issues with Accept headers or proxy configurations
|
||||
response = json_success(payload, 200)
|
||||
# Explicitly set Content-Type as a safeguard against content negotiation issues
|
||||
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
return response
|
||||
payload = json.dumps(
|
||||
result,
|
||||
default=json.pessimistic_json_iso_dttm_ser,
|
||||
ignore_nan=True,
|
||||
)
|
||||
return json_success(payload, 200)
|
||||
|
||||
@expose("/execute/", methods=("POST",))
|
||||
@protect()
|
||||
@@ -426,11 +410,8 @@ class SqlLabRestApi(BaseSupersetApi):
|
||||
if command_result["status"] == SqlJsonExecutionStatus.QUERY_IS_RUNNING
|
||||
else 200
|
||||
)
|
||||
# Return the execution result without special encoding
|
||||
# Set explicit Content-Type to prevent Flask 2.3+ content negotiation issues
|
||||
response = json_success(command_result["payload"], response_status)
|
||||
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
return response
|
||||
# return the execution result without special encoding
|
||||
return json_success(command_result["payload"], response_status)
|
||||
except SqlLabException as ex:
|
||||
payload = {"errors": [ex.to_dict()]}
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@ class AdhocColumn(TypedDict, total=False):
|
||||
hasCustomLabel: Optional[bool]
|
||||
label: str
|
||||
sqlExpression: str
|
||||
isColumnReference: Optional[bool]
|
||||
columnType: Optional[Literal["BASE_AXIS", "SERIES"]]
|
||||
timeGrain: Optional[str]
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ msgstr ""
|
||||
"POT-Creation-Date: 2025-04-29 12:34+0330\n"
|
||||
"PO-Revision-Date: 2016-05-02 08:49-0700\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: es\n"
|
||||
"Language-Team: Español; Castellano <>\n"
|
||||
"Language: en\n"
|
||||
"Language-Team: en <LL@li.org>\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@@ -91,10 +91,6 @@ msgstr " uno nuevo"
|
||||
msgid " at line %(line)d"
|
||||
msgstr " en la línea %(line)d"
|
||||
|
||||
#, python-format
|
||||
msgid " at line %(line)d"
|
||||
msgstr ""
|
||||
|
||||
msgid " expression which needs to adhere to the "
|
||||
msgstr " expresión que debe adherirse al "
|
||||
|
||||
@@ -188,14 +184,7 @@ msgstr "la frecuencia de %(report_type)s programación excede el límite. Config
|
||||
|
||||
#, python-format
|
||||
msgid "%(rows)d rows returned"
|
||||
msgstr "líneas obtenidas"
|
||||
|
||||
#, python-format
|
||||
msgid ""
|
||||
"%(subtitle)s\n"
|
||||
"This may be triggered by:\n"
|
||||
" %(issue)s"
|
||||
msgstr ""
|
||||
msgstr "%(rows)d filas devueltas"
|
||||
|
||||
#, fuzzy, python-format
|
||||
msgid "%(suggestion)s instead of \"%(undefinedParameter)s?\""
|
||||
@@ -266,7 +255,7 @@ msgstr "%s elementos no se han podido etiquetar porque no tienes permisos de edi
|
||||
msgid "%s operator(s)"
|
||||
msgstr "%s operador(es)"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s option"
|
||||
msgid_plural "%s options"
|
||||
msgstr[0] "%s opción"
|
||||
@@ -280,7 +269,7 @@ msgstr "%s opción(es)"
|
||||
msgid "%s recipients"
|
||||
msgstr "%s destinatarios"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "%s row"
|
||||
msgid_plural "%s rows"
|
||||
msgstr[0] "%s fila"
|
||||
@@ -1076,14 +1065,14 @@ msgstr "La consulta de alerta ha devuelto más de una columna."
|
||||
|
||||
#, python-format
|
||||
msgid "Alert query returned more than one column. %(num_cols)s columns returned"
|
||||
msgstr "La consulta de alerta devolvió más de una columna. %(num_cols)s columnas devueltas"
|
||||
msgstr "La consulta de alerta ha devuelto más de una columna. %(num_cols)s columnas devueltas"
|
||||
|
||||
msgid "Alert query returned more than one row."
|
||||
msgstr "La consulta de alerta ha devuelto más de una fila."
|
||||
|
||||
#, python-format
|
||||
msgid "Alert query returned more than one row. %(num_rows)s rows returned"
|
||||
msgstr "La consulta de alerta devolvió más de una fila. %(num_rows)s filas devueltas"
|
||||
msgstr "La consulta de alerta ha devuelto más de una fila. %(num_rows)s filas devueltas"
|
||||
|
||||
msgid "Alert running"
|
||||
msgstr "Alerta en ejecución"
|
||||
@@ -1253,7 +1242,7 @@ msgid "An error occurred while creating %ss: %s"
|
||||
msgstr "Se ha producido un error al crear %ss: %s"
|
||||
|
||||
msgid "An error occurred while creating the copy link."
|
||||
msgstr "Se produjo un error en la creación %ss: %s"
|
||||
msgstr "Se ha producido un error al crear el enlace de copia."
|
||||
|
||||
msgid "An error occurred while creating the data source"
|
||||
msgstr "Se ha producido un error al crear la fuente de datos"
|
||||
@@ -1350,7 +1339,7 @@ msgstr "Se ha producido un error al recuperar los valores del usuario: %s"
|
||||
|
||||
#, python-format
|
||||
msgid "An error occurred while importing %s: %s"
|
||||
msgstr "Se produjo un error importando %s: %s"
|
||||
msgstr "Se ha producido un error al importar %s: %s"
|
||||
|
||||
msgid "An error occurred while loading dashboard information."
|
||||
msgstr "Se ha producido un error al cargar la información del panel de control."
|
||||
@@ -1546,7 +1535,7 @@ msgstr "Filtros aplicados (%s)"
|
||||
|
||||
#, python-format
|
||||
msgid "Applied filters: %s"
|
||||
msgstr "Filtros aplicados %s"
|
||||
msgstr "Filtros aplicados: %s"
|
||||
|
||||
msgid ""
|
||||
"Applied rolling window did not return any data. Please make sure the "
|
||||
@@ -1588,7 +1577,7 @@ msgstr "¿Seguro que quieres eliminar?"
|
||||
|
||||
#, python-format
|
||||
msgid "Are you sure you want to delete %s?"
|
||||
msgstr "¿Está seguro de que desea eliminar %s?"
|
||||
msgstr "¿Seguro que quieres eliminar %s?"
|
||||
|
||||
#, python-format
|
||||
msgid "Are you sure you want to delete the selected %s?"
|
||||
@@ -2249,7 +2238,7 @@ msgstr "Opciones del gráfico"
|
||||
msgid "Chart Orientation"
|
||||
msgstr "Orientación del gráfico"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "Chart Owner: %s"
|
||||
msgid_plural "Chart Owners: %s"
|
||||
msgstr[0] "Propietario del gráfico: %s"
|
||||
@@ -2263,15 +2252,15 @@ msgstr "Título del gráfico"
|
||||
|
||||
#, python-format
|
||||
msgid "Chart [%s] has been overwritten"
|
||||
msgstr "El gráfico [%s] ha sido sobreescrito"
|
||||
msgstr "El gráfico [%s] se ha sobrescrito"
|
||||
|
||||
#, python-format
|
||||
msgid "Chart [%s] has been saved"
|
||||
msgstr "El gráfico [%s] ha sido guardado"
|
||||
msgstr "El gráfico [%s] se ha guardado"
|
||||
|
||||
#, python-format
|
||||
msgid "Chart [%s] was added to dashboard [%s]"
|
||||
msgstr "El gráfico [%s] ha sido añadido al panel de control [%s]"
|
||||
msgstr "El gráfico [%s] se ha añadido al panel de control [%s]"
|
||||
|
||||
msgid "Chart [{}] has been overwritten"
|
||||
msgstr "El gráfico [{}] se ha sobrescrito"
|
||||
@@ -2807,7 +2796,7 @@ msgid "Configuration"
|
||||
msgstr "Configuración"
|
||||
|
||||
msgid "Configure Advanced Time Range "
|
||||
msgstr "Configuración avanzada de rango de tiempo "
|
||||
msgstr "Configurar el intervalo de tiempo avanzado "
|
||||
|
||||
msgid "Configure Time Range: Current..."
|
||||
msgstr "Configurar el intervalo de tiempo: actual..."
|
||||
@@ -2816,7 +2805,7 @@ msgid "Configure Time Range: Last..."
|
||||
msgstr "Configurar el intervalo de tiempo: último..."
|
||||
|
||||
msgid "Configure Time Range: Previous..."
|
||||
msgstr "Configurar Rango de Tiempo: Anteriores..."
|
||||
msgstr "Configurar el intervalo de tiempo: anterior..."
|
||||
|
||||
msgid "Configure custom time range"
|
||||
msgstr "Configurar intervalo de tiempo personalizado"
|
||||
@@ -3825,7 +3814,7 @@ msgid_plural "Deleted %(num)d report schedules"
|
||||
msgstr[0] "Se ha eliminado%(num)d programación de informe"
|
||||
msgstr[1] "Se han eliminado%(num)d programaciones de informe"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "Deleted %(num)d rules"
|
||||
msgid_plural "Deleted %(num)d rules"
|
||||
msgstr[0] "Se han eliminado%(num)d reglas"
|
||||
@@ -4123,11 +4112,13 @@ msgstr "El desglose en detalle está deshabilitado para esta base de datos. Camb
|
||||
msgid "Drill to detail: %s"
|
||||
msgstr "Desglosar en detalle: %s"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Drop a column here or click"
|
||||
msgid_plural "Drop columns here or click"
|
||||
msgstr[0] "Suelta una columna aquí o haz clic"
|
||||
msgstr[1] "Suelta las columnas aquí o haz clic"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Drop a column/metric here or click"
|
||||
msgid_plural "Drop columns/metrics here or click"
|
||||
msgstr[0] "Suelta una columna/métrica aquí o haz clic"
|
||||
@@ -4194,10 +4185,10 @@ msgid "Duration in ms (1.40008 => 1ms 400µs 80ns)"
|
||||
msgstr "Duración en ms (1,40008 => 1 ms 400 µs 80 ns)"
|
||||
|
||||
msgid "Duration in ms (100.40008 => 100ms 400µs 80ns)"
|
||||
msgstr "Duración en ms (100.40008 => 100 ms 400 µs 80 ns)"
|
||||
msgstr "Duración en ms (100,40008 => 100 ms 400 µs 80 ns)"
|
||||
|
||||
msgid "Duration in ms (10500 => 0:10.5)"
|
||||
msgstr "Duración en ms (10500 => 0:10.5)"
|
||||
msgstr "Duración en ms (10 500 => 0:10,5)"
|
||||
|
||||
msgid "Duration in ms (66000 => 1m 6s)"
|
||||
msgstr "Duración en ms (66 000 => 1 m 6 s)"
|
||||
@@ -4244,9 +4235,6 @@ msgstr "Editar CSS"
|
||||
msgid "Edit CSS template properties"
|
||||
msgstr "Editar propiedades de la plantilla CSS"
|
||||
|
||||
msgid "Edit Chart"
|
||||
msgstr "Editar Gráfico"
|
||||
|
||||
msgid "Edit Chart Properties"
|
||||
msgstr "Editar propiedades del gráfico"
|
||||
|
||||
@@ -4381,7 +4369,7 @@ msgid "Embed dashboard"
|
||||
msgstr "Incrustar panel de control"
|
||||
|
||||
msgid "Embedded dashboard could not be deleted."
|
||||
msgstr "El panel de control no pudo ser eliminado."
|
||||
msgstr "No se ha podido eliminar el panel de control incrustado."
|
||||
|
||||
msgid "Embedding deactivated."
|
||||
msgstr "Incrustación desactivada."
|
||||
@@ -4465,7 +4453,7 @@ msgid "End date"
|
||||
msgstr "Fecha final"
|
||||
|
||||
msgid "End date excluded from time range"
|
||||
msgstr "Fecha final excluida del rango de tiempo"
|
||||
msgstr "Fecha final excluida del intervalo de tiempo"
|
||||
|
||||
msgid "End date must be after start date"
|
||||
msgstr "La fecha final debe ser posterior a la fecha inicial"
|
||||
@@ -4584,6 +4572,9 @@ msgstr "Error al leer el archivo de Excel"
|
||||
msgid "Error saving dataset"
|
||||
msgstr "Error al guardar el conjunto de datos"
|
||||
|
||||
msgid "Error unfaving chart"
|
||||
msgstr "Error al quitar el gráfico de favoritos"
|
||||
|
||||
msgid "Error while adding role!"
|
||||
msgstr "Error al añadir el rol"
|
||||
|
||||
@@ -4593,9 +4584,6 @@ msgstr "Error al añadir el usuario"
|
||||
msgid "Error while duplicating role!"
|
||||
msgstr "Error al duplicar el rol"
|
||||
|
||||
msgid "Error unfaving chart"
|
||||
msgstr "Error al quitar el gráfico de favoritos"
|
||||
|
||||
msgid "Error while fetching charts"
|
||||
msgstr "Error al recuperar los gráficos"
|
||||
|
||||
@@ -5230,10 +5218,10 @@ msgid "Geometry Column"
|
||||
msgstr "Columna de geometría"
|
||||
|
||||
msgid "Get the last date by the date unit."
|
||||
msgstr "Obtiene la última fecha para la unidad de fecha especificada."
|
||||
msgstr "Obtener la última fecha por la unidad de fecha."
|
||||
|
||||
msgid "Get the specify date for the holiday"
|
||||
msgstr "Obtiene la fecha del día feriado especificado"
|
||||
msgstr "Obtener la fecha especificada para el día festivo"
|
||||
|
||||
msgid "Give access to multiple catalogs in a single database connection."
|
||||
msgstr "Da acceso a múltiples catálogos en una sola conexión de base de datos."
|
||||
@@ -5508,7 +5496,7 @@ msgstr "Incluye una descripción que se enviará con tu informe"
|
||||
|
||||
#, python-format
|
||||
msgid "Include description to be sent with %s"
|
||||
msgstr "Incluye una descripción para ser enviada con %s"
|
||||
msgstr "Incluye una descripción para enviarse con %s"
|
||||
|
||||
msgid "Include series name as an axis"
|
||||
msgstr "Incluir el nombre de la serie como eje"
|
||||
@@ -5596,7 +5584,7 @@ msgstr "JSON no válido"
|
||||
|
||||
#, python-format
|
||||
msgid "Invalid advanced data type: %(advanced_data_type)s"
|
||||
msgstr "Tipo de información avanzada inválida: %(advanced_data_type)s"
|
||||
msgstr "Tipo de datos avanzados no válido: %(advanced_data_type)s"
|
||||
|
||||
msgid "Invalid certificate"
|
||||
msgstr "Certificado no válido"
|
||||
@@ -5669,7 +5657,7 @@ msgstr "Referencia no válida a la columna: «%(column)s»"
|
||||
|
||||
#, python-format
|
||||
msgid "Invalid result type: %(result_type)s"
|
||||
msgstr "Tipo de resultado inválido: %(result_type)s"
|
||||
msgstr "Tipo de resultado no válido: %(result_type)s"
|
||||
|
||||
#, python-format
|
||||
msgid "Invalid rolling_type: %(type)s"
|
||||
@@ -5677,7 +5665,7 @@ msgstr "Tipo móvil no válido: %(type)s "
|
||||
|
||||
#, python-format
|
||||
msgid "Invalid spatial point encountered: %(latlong)s"
|
||||
msgstr "Se encontró un punto espacial inválido: %(latlong)s"
|
||||
msgstr "Se ha encontrado un punto espacial no válido: %(latlong)s"
|
||||
|
||||
msgid "Invalid state."
|
||||
msgstr "Estado no válido."
|
||||
@@ -6463,7 +6451,7 @@ msgid "Middle"
|
||||
msgstr "Medio"
|
||||
|
||||
msgid "Midnight"
|
||||
msgstr "Media noche"
|
||||
msgstr "Medianoche"
|
||||
|
||||
msgid "Miles"
|
||||
msgstr "Millas"
|
||||
@@ -6574,7 +6562,7 @@ msgstr "Mes"
|
||||
|
||||
#, python-format
|
||||
msgid "Months %s"
|
||||
msgstr "Meses %s"
|
||||
msgstr "Meses %s "
|
||||
|
||||
msgid "More"
|
||||
msgstr "Más"
|
||||
@@ -6589,7 +6577,7 @@ msgid "Move only"
|
||||
msgstr "Solo mover"
|
||||
|
||||
msgid "Moves the given set of dates by a specified interval."
|
||||
msgstr "Desplaza el conjunto de fechas dado en un intervalo especificado."
|
||||
msgstr "Mueve el conjunto de fechas en cuestión por un intervalo especificado."
|
||||
|
||||
msgid "Multi-Dimensions"
|
||||
msgstr "Multidimensional"
|
||||
@@ -6809,7 +6797,7 @@ msgid "No entities have this tag currently assigned"
|
||||
msgstr "Ninguna entidad tiene esta etiqueta asignada actualmente"
|
||||
|
||||
msgid "No filter"
|
||||
msgstr "Sin filtro"
|
||||
msgstr "No hay ningún filtro"
|
||||
|
||||
msgid "No filter is selected."
|
||||
msgstr "No se ha seleccionado ningún filtro."
|
||||
@@ -6833,7 +6821,7 @@ msgid "No records found"
|
||||
msgstr "No se han encontrado registros"
|
||||
|
||||
msgid "No results"
|
||||
msgstr "Sin resultados"
|
||||
msgstr "No hay resultados"
|
||||
|
||||
msgid "No results found"
|
||||
msgstr "No se han encontrado resultados"
|
||||
@@ -6842,7 +6830,7 @@ msgid "No results match your filter criteria"
|
||||
msgstr "No hay resultados que coincidan con tus criterios de filtro"
|
||||
|
||||
msgid "No results were returned for this query"
|
||||
msgstr "No se obtuvieron resultados para esta consulta"
|
||||
msgstr "No se han devuelto resultados para esta consulta"
|
||||
|
||||
msgid ""
|
||||
"No results were returned for this query. If you expected results to be "
|
||||
@@ -7085,7 +7073,7 @@ msgid "One or many metrics to display"
|
||||
msgstr "Una o varias métricas a mostrar"
|
||||
|
||||
msgid "One or more annotation layers failed loading."
|
||||
msgstr "Una o más capas de anotación fallaron al cargar."
|
||||
msgstr "No se han podido cargar una o más capas de anotación."
|
||||
|
||||
msgid "One or more columns already exist"
|
||||
msgstr "Una o más columnas ya existen"
|
||||
@@ -7501,7 +7489,7 @@ msgid "Pie Chart"
|
||||
msgstr "Gráfico tipo pastel"
|
||||
|
||||
msgid "Pie charts on a map"
|
||||
msgstr "Mapa con gráficos tipo pastel"
|
||||
msgstr "Gráficos tipo pastel en un mapa"
|
||||
|
||||
msgid "Pie shape"
|
||||
msgstr "Forma de pastel"
|
||||
@@ -7602,6 +7590,7 @@ msgstr "Vuelve a introducir tu contraseña."
|
||||
msgid "Please re-export your file and try importing again"
|
||||
msgstr "Vuelve a exportar tu archivo e intenta importarlo de nuevo"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Please reach out to the Chart Owner for assistance."
|
||||
msgid_plural "Please reach out to the Chart Owners for assistance."
|
||||
msgstr[0] "Ponte en contacto con el propietario del gráfico para obtener ayuda."
|
||||
@@ -8018,7 +8007,7 @@ msgid "Relationships between community channels"
|
||||
msgstr "Relaciones entre canales comunitarios"
|
||||
|
||||
msgid "Relative Date/Time"
|
||||
msgstr "Fecha/Hora Relativa"
|
||||
msgstr "Fecha/hora relativa"
|
||||
|
||||
msgid "Relative period"
|
||||
msgstr "Periodo relativo"
|
||||
@@ -8763,9 +8752,6 @@ msgstr "Selecciona el método de entrega"
|
||||
msgid "Select Tags"
|
||||
msgstr "Seleccionar etiquetas"
|
||||
|
||||
msgid "Select Viz Type"
|
||||
msgstr "Selecciona un tipo de visualización"
|
||||
|
||||
msgid "Select chart type"
|
||||
msgstr "Seleccionar tipo de visualización"
|
||||
|
||||
@@ -9155,21 +9141,12 @@ msgstr "Mostrar burbujas"
|
||||
msgid "Show CREATE VIEW statement"
|
||||
msgstr "Mostrar instrucción CREAR VISTA"
|
||||
|
||||
msgid "Show Cell bars"
|
||||
msgstr "Todos los gráficos"
|
||||
|
||||
msgid "Show Chart"
|
||||
msgstr "Mostrar Gráfico"
|
||||
|
||||
msgid "Show Column"
|
||||
msgstr "Mostrar Columna"
|
||||
msgid "Show cell bars"
|
||||
msgstr "Mostrar barras de celda"
|
||||
|
||||
msgid "Show Dashboard"
|
||||
msgstr "Mostrar el panel de control"
|
||||
|
||||
msgid "Show Database"
|
||||
msgstr "Mostrar Base de Datos"
|
||||
|
||||
msgid "Show Labels"
|
||||
msgstr "Mostrar etiquetas"
|
||||
|
||||
@@ -9179,9 +9156,6 @@ msgstr "Mostrar registro"
|
||||
msgid "Show Markers"
|
||||
msgstr "Mostrar marcadores"
|
||||
|
||||
msgid "Show Metric"
|
||||
msgstr "Mostrar Métrica"
|
||||
|
||||
msgid "Show Metric Names"
|
||||
msgstr "Mostrar nombres de las métricas"
|
||||
|
||||
@@ -9449,7 +9423,7 @@ msgstr "Lo sentimos, se ha producido un error. Inténtalo de nuevo más tarde."
|
||||
|
||||
#, python-format
|
||||
msgid "Sorry, there was an error saving this %s: %s"
|
||||
msgstr "Lo sentimos, se ha producido un error al guardar esto %s: %s"
|
||||
msgstr "Lo sentimos, se ha producido un error al guardar este %s: %s"
|
||||
|
||||
#, python-format
|
||||
msgid "Sorry, there was an error saving this dashboard: %s"
|
||||
@@ -9538,7 +9512,7 @@ msgid "Spatial"
|
||||
msgstr "Espacial"
|
||||
|
||||
msgid "Specific Date/Time"
|
||||
msgstr "Fecha/Hora Específica"
|
||||
msgstr "Fecha/hora específica"
|
||||
|
||||
msgid "Specify name to CREATE TABLE AS schema in: public"
|
||||
msgstr "Especifica el nombre para el esquema CREAR TABLA COMO en: público"
|
||||
@@ -9603,7 +9577,7 @@ msgid "Start date"
|
||||
msgstr "Fecha de inicio"
|
||||
|
||||
msgid "Start date included in time range"
|
||||
msgstr "Fecha inicial incluida en el rango de tiempo"
|
||||
msgstr "Fecha de inicio incluida en el intervalo de tiempo"
|
||||
|
||||
msgid "Start y-axis at 0"
|
||||
msgstr "Iniciar eje Y en 0"
|
||||
@@ -9759,9 +9733,6 @@ msgstr "Documentación del SDK integrado de Superset."
|
||||
msgid "Superset chart"
|
||||
msgstr "Gráfico Superset"
|
||||
|
||||
msgid "Superset dashboard"
|
||||
msgstr "Dashboard Superset"
|
||||
|
||||
msgid "Superset encountered an error while running a command."
|
||||
msgstr "Superset ha encontrado un error al ejecutar un comando."
|
||||
|
||||
@@ -9879,7 +9850,7 @@ msgstr "No se ha definido el nombre de la tabla"
|
||||
|
||||
#, python-format
|
||||
msgid "Table or View \"%(table)s\" does not exist."
|
||||
msgstr "La tabla o vista \"%(table)s\" no existe"
|
||||
msgstr "La tabla o la vista «%(table)s» no existen."
|
||||
|
||||
msgid ""
|
||||
"Table that visualizes paired t-tests, which are used to understand "
|
||||
@@ -10746,9 +10717,7 @@ msgid "There was an error loading the tables"
|
||||
msgstr "Se ha producido un error al cargar las tablas"
|
||||
|
||||
msgid "There was an error retrieving dashboard tabs."
|
||||
msgstr ""
|
||||
"Lo sentimos, hubo un error al obtener la información de la base de datos:"
|
||||
" %s"
|
||||
msgstr "Se ha producido un error al recuperar las pestañas del panel."
|
||||
|
||||
#, python-format
|
||||
msgid "There was an error saving the favorite status: %s"
|
||||
@@ -10767,7 +10736,7 @@ msgstr "Ha habido un problema al eliminar %s: %s"
|
||||
|
||||
#, python-format
|
||||
msgid "There was an issue deleting rules: %s"
|
||||
msgstr "Hubo un problema eliminando las reglas: %s"
|
||||
msgstr "Ha habido un problema al eliminar las reglas: %s"
|
||||
|
||||
#, python-format
|
||||
msgid "There was an issue deleting the selected %s: %s"
|
||||
@@ -10826,11 +10795,11 @@ msgstr "Ha habido un problema al recuperar tu gráfico: %s "
|
||||
|
||||
#, python-format
|
||||
msgid "There was an issue fetching your dashboards: %s"
|
||||
msgstr "Hubo un problema al obtener tus dashboards: %s"
|
||||
msgstr "Ha habido un problema al recuperar tus paneles de control: %s"
|
||||
|
||||
#, python-format
|
||||
msgid "There was an issue fetching your recent activity: %s"
|
||||
msgstr "Hubo un error al obtener tu actividad reciente: %s"
|
||||
msgstr "Ha habido un problema al recuperar tu actividad reciente: %s"
|
||||
|
||||
#, python-format
|
||||
msgid "There was an issue fetching your saved queries: %s"
|
||||
@@ -10870,7 +10839,7 @@ msgid "This action will permanently delete the template."
|
||||
msgstr "Esta acción eliminará permanentemente la plantilla."
|
||||
|
||||
msgid "This action will permanently delete the user."
|
||||
msgstr "Esta acción eliminará permanentemente el usuario."
|
||||
msgstr "Esta acción eliminará permanentemente el uduario."
|
||||
|
||||
msgid ""
|
||||
"This can be either an IP address (e.g. 127.0.0.1) or a domain name (e.g. "
|
||||
@@ -11079,6 +11048,7 @@ msgstr "Este tipo de visualización no admite el filtro cruzado."
|
||||
msgid "This visualization type is not supported."
|
||||
msgstr "Este tipo de visualización no se admite."
|
||||
|
||||
#, fuzzy
|
||||
msgid "This was triggered by:"
|
||||
msgid_plural "This may be triggered by:"
|
||||
msgstr[0] "La causa de esto ha sido:"
|
||||
@@ -11357,33 +11327,7 @@ msgid "Tree layout"
|
||||
msgstr "Diseño del árbol"
|
||||
|
||||
msgid "Tree orientation"
|
||||
Findings (brief):
|
||||
|
||||
- No git merge conflict markers found (no <<<<<<< / ======= / >>>>>>>).
|
||||
- PO header mismatch: "Language: en" — this is an Spanish file; set to "es".
|
||||
- Duplicate msgid entries with conflicting/empty translations:
|
||||
- " at line %(line)d" — one entry has " en la línea %(line)d", another has an empty msgstr.
|
||||
- "Dashboard cannot be copied due to invalid parameters." — appears multiple times with different/empty msgstr values.
|
||||
- "%(subtitle)s\nThis may be triggered by:\n %(issue)s" — msgstr is empty in one occurrence.
|
||||
- There are other repeated msgids with one occurrence left untranslated (examples: search for repeated msgid strings with one msgstr == "").
|
||||
- Empty translations (examples):
|
||||
- msgid "%(subtitle)s\nThis may be triggered by:\n %(issue)s" → msgstr "".
|
||||
- Several other msgid entries have msgstr "" (scan for msgstr "" occurrences).
|
||||
- Fuzzy entries present (e.g. entries annotated "#, fuzzy") — these need review and removal of the fuzzy flag after correction.
|
||||
- Typo in a translation: msgid "This action will permanently delete the user." → msgstr contains "uduario." (should be "usuario.").
|
||||
|
||||
Recommended next steps:
|
||||
- Fix header Language to "es".
|
||||
- Remove/fix duplicate msgids: consolidate into a single entry and keep the correct translation.
|
||||
- Fill in missing msgstr values (or mark as untranslated intentionally).
|
||||
- Review and resolve fuzzy entries, then remove the "fuzzy" flag.
|
||||
- Fix obvious typos (e.g., "uduario" → "usuario").
|
||||
|
||||
If you want, I can produce a patch that:
|
||||
- updates header Language to "es",
|
||||
- removes duplicate entries by keeping the first translated occurrence,
|
||||
- lists all msgids with empty msgstr for you to translate,
|
||||
or show exact locations (line ranges) for each problem. Which would you prefer?
|
||||
msgstr "Orientación del árbol"
|
||||
|
||||
msgid "Treemap"
|
||||
msgstr "Diagrama de árbol"
|
||||
@@ -12039,7 +11983,7 @@ msgstr "WMS"
|
||||
|
||||
#, python-format
|
||||
msgid "Waiting on %s"
|
||||
msgstr "Esperando por %s"
|
||||
msgstr "Esperando a %s"
|
||||
|
||||
msgid "Waiting on database..."
|
||||
msgstr "Esperando a la base de datos..."
|
||||
@@ -12139,7 +12083,7 @@ msgstr "Semanas %s"
|
||||
msgid "Weight"
|
||||
msgstr "Peso"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid ""
|
||||
"We’re having trouble loading these results. Queries are set to timeout "
|
||||
"after %s second."
|
||||
@@ -12149,7 +12093,7 @@ msgid_plural ""
|
||||
msgstr[0] "Estamos teniendo problemas para cargar estos resultados. Se considera que una consulta ha superado el tiempo de espera después de %s segundo."
|
||||
msgstr[1] "Estamos teniendo problemas para cargar estos resultados. Se considera que una consulta ha superado el tiempo de espera después de %s segundos."
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid ""
|
||||
"We’re having trouble loading this visualization. Queries are set to "
|
||||
"timeout after %s second."
|
||||
@@ -12632,7 +12576,7 @@ msgstr "No puedes utilizar el diseño de marca de 45° con el filtro de interval
|
||||
|
||||
#, python-format
|
||||
msgid "You do not have permission to edit this %s"
|
||||
msgstr "No tienes permisos para editar esto %s"
|
||||
msgstr "No tienes permisos para editar este %s"
|
||||
|
||||
msgid "You do not have permission to edit this chart"
|
||||
msgstr "No tienes permisos para editar este gráfico"
|
||||
@@ -12897,7 +12841,7 @@ msgid "background"
|
||||
msgstr "fondo"
|
||||
|
||||
msgid "Basic conditional formatting"
|
||||
msgstr "Formato condicional básico"
|
||||
msgstr "formato condicional básico"
|
||||
|
||||
msgid "basis"
|
||||
msgstr "base"
|
||||
@@ -13220,18 +13164,13 @@ msgid ""
|
||||
"is linked to %s charts that appear on %s dashboards and users have %s SQL"
|
||||
" Lab tabs using this database open. Are you sure you want to continue? "
|
||||
"Deleting the database will break those objects."
|
||||
msgstr ""
|
||||
"La base de datos %s está vinculada a %s gráficos que aparecen en %s "
|
||||
"dashboards. ¿Estás seguro de que quieres continuar? Eliminar la base de "
|
||||
"datos dejará inutilizables esos objetos."
|
||||
msgstr "está vinculado a %s gráficos que aparecen en %s paneles de control y los usuarios tienen %s pestañas de SQL Lab usando esta base de datos abierta. ¿Seguro que quieres continuar? Eliminar la base de datos descompondrá esos objetos."
|
||||
|
||||
#, python-format
|
||||
msgid ""
|
||||
"is linked to %s charts that appear on %s dashboards. Are you sure you "
|
||||
"want to continue? Deleting the dataset will break those objects."
|
||||
msgstr ""
|
||||
"esta linkeado a %s gráficos que aparecen en %s tableros. ¿Está seguro"
|
||||
"de que desea continuar? Eliminar el conjunto de datos romperá esos objetos."
|
||||
msgstr "está vinculado a %s gráficos que aparecen en %s paneles de control. ¿Seguro que quieres continuar? Eliminar el conjunto de datos descompondrá esos objetos."
|
||||
|
||||
msgid "is not"
|
||||
msgstr "no es"
|
||||
@@ -13371,19 +13310,16 @@ msgid "pixels"
|
||||
msgstr "píxeles"
|
||||
|
||||
msgid "previous calendar month"
|
||||
msgstr "mes anterior"
|
||||
msgstr "mes natural anterior"
|
||||
|
||||
msgid "previous calendar quarter"
|
||||
msgstr "trimestre anterior"
|
||||
msgstr "trimestre natural anterior"
|
||||
|
||||
msgid "previous calendar week"
|
||||
msgstr "semana anterior"
|
||||
msgstr "semana natural anterior"
|
||||
|
||||
msgid "previous calendar year"
|
||||
msgstr "año anterior"
|
||||
|
||||
msgid "published"
|
||||
msgstr "No publicado"
|
||||
msgstr "año natural anterior"
|
||||
|
||||
msgid "quarter"
|
||||
msgstr "trimestre"
|
||||
@@ -13403,9 +13339,6 @@ msgstr "reiniciar"
|
||||
msgid "recent"
|
||||
msgstr "reciente"
|
||||
|
||||
msgid "recents"
|
||||
msgstr "Recientes"
|
||||
|
||||
msgid "recipients"
|
||||
msgstr "destinatarios"
|
||||
|
||||
@@ -13427,9 +13360,6 @@ msgstr "rowlevelsecurity"
|
||||
msgid "running"
|
||||
msgstr "en ejecución"
|
||||
|
||||
msgid "saved queries"
|
||||
msgstr "Consultas Guardadas"
|
||||
|
||||
msgid "save"
|
||||
msgstr "guardar"
|
||||
|
||||
|
||||
@@ -477,20 +477,10 @@ def get_since_until( # pylint: disable=too-many-arguments,too-many-locals,too-m
|
||||
)
|
||||
|
||||
if time_shift:
|
||||
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)
|
||||
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,9 +25,6 @@ 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
|
||||
@@ -80,7 +77,7 @@ def combine_screenshot_tiles(screenshot_tiles: list[bytes]) -> bytes:
|
||||
|
||||
|
||||
def take_tiled_screenshot(
|
||||
page: "Page", element_name: str, tile_height: int
|
||||
page: "Page", element_name: str, viewport_height: int = 2000
|
||||
) -> bytes | None:
|
||||
"""
|
||||
Take a tiled screenshot of a large dashboard by scrolling and capturing sections.
|
||||
@@ -88,7 +85,7 @@ def take_tiled_screenshot(
|
||||
Args:
|
||||
page: Playwright page object
|
||||
element_name: CSS class name of the element to screenshot
|
||||
tile_height: Height of each tile in pixels
|
||||
viewport_height: Height of each tile in pixels
|
||||
|
||||
Returns:
|
||||
Combined screenshot bytes or None if failed
|
||||
@@ -103,17 +100,17 @@ def take_tiled_screenshot(
|
||||
const el = document.querySelector(".{element_name}");
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {{
|
||||
width: el.scrollWidth,
|
||||
height: el.scrollHeight,
|
||||
left: rect.left + window.scrollX,
|
||||
top: rect.top + window.scrollY,
|
||||
left: rect.left + window.scrollX,
|
||||
width: el.scrollWidth
|
||||
}};
|
||||
}}""")
|
||||
|
||||
dashboard_width = element_info["width"]
|
||||
dashboard_height = element_info["height"]
|
||||
dashboard_left = element_info["left"]
|
||||
dashboard_top = element_info["top"]
|
||||
dashboard_left = element_info["left"]
|
||||
dashboard_width = element_info["width"]
|
||||
|
||||
logger.info(
|
||||
"Dashboard: %sx%spx at (%s, %s)",
|
||||
@@ -124,54 +121,62 @@ def take_tiled_screenshot(
|
||||
)
|
||||
|
||||
# Calculate number of tiles needed
|
||||
num_tiles = max(1, (dashboard_height + tile_height - 1) // tile_height)
|
||||
num_tiles = max(1, (dashboard_height + viewport_height - 1) // viewport_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 * tile_height)
|
||||
scroll_y = dashboard_top + (i * viewport_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(SCROLL_SETTLE_TIMEOUT_MS)
|
||||
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
|
||||
}};
|
||||
}}""")
|
||||
|
||||
# Calculate what portion of the element we want to capture for this tile
|
||||
tile_start_in_element = i * tile_height
|
||||
tile_start_in_element = i * viewport_height
|
||||
remaining_content = dashboard_height - tile_start_in_element
|
||||
clip_height = min(tile_height, remaining_content)
|
||||
clip_y = (
|
||||
0
|
||||
if tile_height < remaining_content
|
||||
else tile_height - remaining_content
|
||||
)
|
||||
clip_x = dashboard_left
|
||||
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"])
|
||||
|
||||
# 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 or clip_y < 0:
|
||||
if clip_height <= 0:
|
||||
logger.warning(
|
||||
"Skipping tile %s/%s due to invalid clip dimensions: "
|
||||
"x=%s, y=%s, width=%s, height=%s "
|
||||
"(element may be scrolled out of viewport)",
|
||||
"width=%s, height=%s (element may be scrolled out of viewport)",
|
||||
i + 1,
|
||||
num_tiles,
|
||||
clip_x,
|
||||
clip_y,
|
||||
dashboard_width,
|
||||
current_element_box["width"],
|
||||
clip_height,
|
||||
)
|
||||
continue
|
||||
|
||||
# Clip to capture only the current tile portion of the element
|
||||
clip = {
|
||||
"x": clip_x,
|
||||
"y": clip_y,
|
||||
"width": dashboard_width,
|
||||
"x": current_element_box["x"],
|
||||
"y": current_element_box["y"],
|
||||
"width": current_element_box["width"],
|
||||
"height": clip_height,
|
||||
}
|
||||
|
||||
@@ -185,6 +190,9 @@ 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,13 +239,11 @@ 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": viewport_height,
|
||||
"width": viewport_width,
|
||||
"height": self._window[1],
|
||||
"width": self._window[0],
|
||||
},
|
||||
device_scale_factor=pixel_density,
|
||||
)
|
||||
@@ -345,15 +343,15 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
height_threshold = app.config.get(
|
||||
"SCREENSHOT_TILED_HEIGHT_THRESHOLD", 5000
|
||||
)
|
||||
tile_height = app.config.get(
|
||||
"SCREENSHOT_TILED_VIEWPORT_HEIGHT", viewport_height
|
||||
viewport_height = app.config.get(
|
||||
"SCREENSHOT_TILED_VIEWPORT_HEIGHT", self._window[1]
|
||||
)
|
||||
|
||||
# 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(
|
||||
@@ -362,11 +360,9 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
chart_count,
|
||||
dashboard_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, viewport_height=viewport_height
|
||||
)
|
||||
img = take_tiled_screenshot(page, element_name, tile_height)
|
||||
if img is None:
|
||||
logger.warning(
|
||||
(
|
||||
|
||||
@@ -753,11 +753,10 @@ class TestPostChartDataApi(BaseTestChartDataApi):
|
||||
|
||||
@with_feature_flags(GLOBAL_ASYNC_QUERIES=True)
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
@mock.patch("superset.extensions.event_logger.log")
|
||||
def test_chart_data_async_cached_sync_response(self, mock_event_logger):
|
||||
def test_chart_data_async_cached_sync_response(self):
|
||||
"""
|
||||
Chart data API: Test chart data query returns results synchronously
|
||||
when results are already cached, and that is_cached is logged.
|
||||
when results are already cached.
|
||||
"""
|
||||
app._got_first_request = False
|
||||
async_query_manager_factory.init_app(app)
|
||||
@@ -768,7 +767,7 @@ class TestPostChartDataApi(BaseTestChartDataApi):
|
||||
|
||||
cmd_run_val = {
|
||||
"query_context": QueryContext(),
|
||||
"queries": [{"query": "select * from foo", "is_cached": True}],
|
||||
"queries": [{"query": "select * from foo"}],
|
||||
}
|
||||
|
||||
with mock.patch.object(
|
||||
@@ -781,16 +780,7 @@ class TestPostChartDataApi(BaseTestChartDataApi):
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
patched_run.assert_called_once_with(force_cached=True)
|
||||
assert data == {
|
||||
"result": [{"query": "select * from foo", "is_cached": True}]
|
||||
}
|
||||
|
||||
# Verify that is_cached was logged to event logger
|
||||
call_kwargs = mock_event_logger.call_args[1]
|
||||
records = call_kwargs.get("records", [])
|
||||
assert len(records) > 0
|
||||
# is_cached should be True when retrieved from cache in async path
|
||||
assert records[0]["is_cached"] is True
|
||||
assert data == {"result": [{"query": "select * from foo"}]}
|
||||
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
@mock.patch("superset.extensions.event_logger.log")
|
||||
|
||||
@@ -20,10 +20,8 @@ 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
|
||||
@@ -120,40 +118,3 @@ 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"
|
||||
|
||||
@@ -126,41 +126,6 @@ class TestSqlLab(SupersetTestCase):
|
||||
"engine_name": engine_name,
|
||||
}
|
||||
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
def test_sql_json_where_clause_content_type(self):
|
||||
"""
|
||||
Test that queries with WHERE clauses return proper Content-Type headers.
|
||||
|
||||
This test addresses issue #36072 where Flask 2.3+ content negotiation
|
||||
could cause HTTP 406 errors for queries with WHERE clauses, particularly
|
||||
when using ENABLE_PROXY_FIX or certain Accept header configurations.
|
||||
"""
|
||||
self.login(ADMIN_USERNAME)
|
||||
|
||||
# Test query with WHERE clause
|
||||
resp = self.client.post(
|
||||
"/api/v1/sqllab/execute/",
|
||||
json={
|
||||
"database_id": self.get_database_by_name("examples").id,
|
||||
"sql": "SELECT * FROM birth_names WHERE name = 'John' LIMIT 5",
|
||||
"client_id": "test_where_1",
|
||||
},
|
||||
)
|
||||
|
||||
# Verify response is successful
|
||||
assert resp.status_code in (200, 202), f"Expected 200/202, got {resp.status_code}"
|
||||
|
||||
# Verify Content-Type header is explicitly set to prevent 406 errors
|
||||
assert "application/json" in resp.headers.get("Content-Type", "")
|
||||
|
||||
# Verify response body is valid JSON
|
||||
data = resp.json
|
||||
assert isinstance(data, dict)
|
||||
|
||||
# If query ran synchronously (200), verify it has data
|
||||
if resp.status_code == 200:
|
||||
assert "data" in data or "query_id" in data
|
||||
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
def test_sql_json_dml_disallowed(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
|
||||
@@ -1,16 +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.
|
||||
@@ -1,325 +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.
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.commands.chart.data.get_data_command import ChartDataCommand
|
||||
from superset.commands.chart.exceptions import ChartDataQueryFailedError
|
||||
from superset.common.chart_data import ChartDataResultType
|
||||
from superset.common.query_context import QueryContext
|
||||
|
||||
|
||||
def test_query_result_type_allows_validation_error_payload() -> None:
|
||||
"""
|
||||
Regression test: Ensure result_type='query' with error payload returns
|
||||
the error instead of raising ChartDataQueryFailedError.
|
||||
|
||||
This locks in the behavior where validation errors are passed through
|
||||
to the frontend for display in ViewQueryModal.
|
||||
|
||||
Context:
|
||||
- GitHub Issue #35492
|
||||
- Superset 4.1.3 allowed errors to pass through
|
||||
- Command reorganization in 2023 broke this behavior
|
||||
- This test ensures errors pass through for query-only requests
|
||||
"""
|
||||
# Mock QueryContext with result_type=QUERY
|
||||
mock_query_context = Mock(spec=QueryContext)
|
||||
mock_query_context.result_type = ChartDataResultType.QUERY
|
||||
mock_query_context.get_payload.return_value = {
|
||||
"queries": [{"error": "Missing temporal column", "language": "sql"}]
|
||||
}
|
||||
|
||||
command = ChartDataCommand(mock_query_context)
|
||||
|
||||
# Should NOT raise - this is the key assertion for the regression test
|
||||
result = command.run()
|
||||
|
||||
# Verify error is passed through in the response
|
||||
assert result["queries"][0]["error"] == "Missing temporal column"
|
||||
assert result["queries"][0]["language"] == "sql"
|
||||
assert "query" not in result["queries"][0] # No SQL for validation errors
|
||||
|
||||
|
||||
def test_full_result_type_raises_on_error() -> None:
|
||||
"""
|
||||
Test that result_type='full' with error raises ChartDataQueryFailedError.
|
||||
|
||||
This ensures data requests continue to fail fast when errors occur,
|
||||
maintaining existing behavior for non-query requests.
|
||||
"""
|
||||
# Mock QueryContext with result_type=FULL
|
||||
mock_query_context = Mock(spec=QueryContext)
|
||||
mock_query_context.result_type = ChartDataResultType.FULL
|
||||
mock_query_context.get_payload.return_value = {
|
||||
"queries": [{"error": "Invalid column name"}]
|
||||
}
|
||||
|
||||
command = ChartDataCommand(mock_query_context)
|
||||
|
||||
# Should raise exception for data requests
|
||||
with pytest.raises(ChartDataQueryFailedError) as exc_info:
|
||||
command.run()
|
||||
|
||||
assert "Invalid column name" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_results_result_type_raises_on_error() -> None:
|
||||
"""
|
||||
Test that result_type='results' with error raises ChartDataQueryFailedError.
|
||||
|
||||
Ensures fail-fast behavior is preserved for results-only requests.
|
||||
"""
|
||||
# Mock QueryContext with result_type=RESULTS
|
||||
mock_query_context = Mock(spec=QueryContext)
|
||||
mock_query_context.result_type = ChartDataResultType.RESULTS
|
||||
mock_query_context.get_payload.return_value = {
|
||||
"queries": [{"error": "Database connection failed"}]
|
||||
}
|
||||
|
||||
command = ChartDataCommand(mock_query_context)
|
||||
|
||||
# Should raise exception for results requests
|
||||
with pytest.raises(ChartDataQueryFailedError) as exc_info:
|
||||
command.run()
|
||||
|
||||
assert "Database connection failed" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_query_result_type_returns_successful_query() -> None:
|
||||
"""
|
||||
Test that result_type='query' without error returns query successfully.
|
||||
|
||||
Ensures no regression for successful query requests.
|
||||
"""
|
||||
# Mock QueryContext with result_type=QUERY and successful query
|
||||
mock_query_context = Mock(spec=QueryContext)
|
||||
mock_query_context.result_type = ChartDataResultType.QUERY
|
||||
mock_query_context.get_payload.return_value = {
|
||||
"queries": [{"query": "SELECT * FROM table", "language": "sql"}]
|
||||
}
|
||||
|
||||
command = ChartDataCommand(mock_query_context)
|
||||
|
||||
# Should return query successfully
|
||||
result = command.run()
|
||||
|
||||
assert result["queries"][0]["query"] == "SELECT * FROM table"
|
||||
assert result["queries"][0]["language"] == "sql"
|
||||
assert "error" not in result["queries"][0]
|
||||
|
||||
|
||||
def test_full_result_type_returns_successful_data() -> None:
|
||||
"""
|
||||
Test that result_type='full' without error returns data successfully.
|
||||
|
||||
Ensures no regression for successful data requests.
|
||||
"""
|
||||
# Mock QueryContext with result_type=FULL and successful data
|
||||
mock_query_context = Mock(spec=QueryContext)
|
||||
mock_query_context.result_type = ChartDataResultType.FULL
|
||||
mock_query_context.get_payload.return_value = {
|
||||
"queries": [{"data": [{"col1": "value1"}], "colnames": ["col1"]}]
|
||||
}
|
||||
|
||||
command = ChartDataCommand(mock_query_context)
|
||||
|
||||
# Should return data successfully
|
||||
result = command.run()
|
||||
|
||||
assert result["queries"][0]["data"] == [{"col1": "value1"}]
|
||||
assert result["queries"][0]["colnames"] == ["col1"]
|
||||
assert "error" not in result["queries"][0]
|
||||
|
||||
|
||||
def test_query_result_type_with_multiple_queries_and_mixed_results() -> None:
|
||||
"""
|
||||
Test that result_type='query' handles multiple queries with mixed results.
|
||||
|
||||
Some queries may succeed while others have validation errors.
|
||||
All should be returned without raising exceptions.
|
||||
"""
|
||||
# Mock QueryContext with multiple queries
|
||||
mock_query_context = Mock(spec=QueryContext)
|
||||
mock_query_context.result_type = ChartDataResultType.QUERY
|
||||
mock_query_context.get_payload.return_value = {
|
||||
"queries": [
|
||||
{"query": "SELECT * FROM table1", "language": "sql"},
|
||||
{"error": "Missing required field", "language": "sql"},
|
||||
{"query": "SELECT * FROM table2", "language": "sql"},
|
||||
]
|
||||
}
|
||||
|
||||
command = ChartDataCommand(mock_query_context)
|
||||
|
||||
# Should return all queries without raising
|
||||
result = command.run()
|
||||
|
||||
# Verify first query succeeded
|
||||
assert result["queries"][0]["query"] == "SELECT * FROM table1"
|
||||
assert "error" not in result["queries"][0]
|
||||
|
||||
# Verify second query has error
|
||||
assert result["queries"][1]["error"] == "Missing required field"
|
||||
assert "query" not in result["queries"][1]
|
||||
|
||||
# Verify third query succeeded
|
||||
assert result["queries"][2]["query"] == "SELECT * FROM table2"
|
||||
assert "error" not in result["queries"][2]
|
||||
|
||||
|
||||
def test_full_result_type_fails_fast_on_first_error_in_multiple_queries() -> None:
|
||||
"""
|
||||
Test that result_type='full' raises on first error even with multiple queries.
|
||||
|
||||
Ensures fail-fast behavior when multiple queries are present.
|
||||
"""
|
||||
# Mock QueryContext with multiple queries where first has error
|
||||
mock_query_context = Mock(spec=QueryContext)
|
||||
mock_query_context.result_type = ChartDataResultType.FULL
|
||||
mock_query_context.get_payload.return_value = {
|
||||
"queries": [
|
||||
{"error": "First query failed"},
|
||||
{"data": [{"col1": "value1"}]},
|
||||
]
|
||||
}
|
||||
|
||||
command = ChartDataCommand(mock_query_context)
|
||||
|
||||
# Should raise on first error without processing remaining queries
|
||||
with pytest.raises(ChartDataQueryFailedError) as exc_info:
|
||||
command.run()
|
||||
|
||||
assert "First query failed" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_get_query_catches_parsing_error() -> None:
|
||||
"""
|
||||
Test that _get_query() catches SupersetParseError and returns both SQL and error.
|
||||
|
||||
When SQL generation succeeds but optimization/parsing fails:
|
||||
- SQL has already been compiled (stored in error.extra['sql'])
|
||||
- Error message describes the parsing failure
|
||||
- Both should be returned to the frontend for display
|
||||
"""
|
||||
from superset.common.query_actions import _get_query
|
||||
from superset.common.query_object import QueryObject
|
||||
from superset.exceptions import SupersetParseError
|
||||
|
||||
# Create mock query_context and query_obj
|
||||
mock_query_context = Mock()
|
||||
mock_query_obj = Mock(spec=QueryObject)
|
||||
|
||||
# Create SupersetParseError with SQL in error.extra
|
||||
parse_error = SupersetParseError(
|
||||
sql="SELECT SUM ( Open", # SQL was generated before parsing failed
|
||||
engine="postgresql",
|
||||
message="Error parsing near 'Open' at line 1:17",
|
||||
line=1,
|
||||
column=17,
|
||||
)
|
||||
|
||||
# Mock _get_datasource and datasource.get_query_str to raise the error
|
||||
with patch("superset.common.query_actions._get_datasource") as mock_get_ds:
|
||||
mock_datasource = Mock()
|
||||
mock_datasource.query_language = "sql"
|
||||
mock_datasource.get_query_str.side_effect = parse_error
|
||||
mock_get_ds.return_value = mock_datasource
|
||||
|
||||
# GREEN: Exception is caught, values returned (new behavior after fix)
|
||||
result = _get_query(mock_query_context, mock_query_obj, False)
|
||||
|
||||
# Should return both query (from error.extra['sql']) and error message
|
||||
assert result["query"] == "SELECT SUM ( Open"
|
||||
assert result["error"] == "Error parsing near 'Open' at line 1:17"
|
||||
assert result["language"] == "sql"
|
||||
|
||||
|
||||
def test_get_query_handles_parsing_error_with_missing_sql_key() -> None:
|
||||
"""
|
||||
Test _get_query() when error.extra exists but 'sql' key is missing.
|
||||
|
||||
Edge case: error.extra = {"other_field": "value"} with no 'sql' key.
|
||||
Should NOT set result["query"] - prevents null from reaching TypeScript.
|
||||
Should still return error message for display.
|
||||
|
||||
Ensures defensive programming when extracting SQL from exception extra data.
|
||||
"""
|
||||
from superset.common.query_actions import _get_query
|
||||
from superset.common.query_object import QueryObject
|
||||
from superset.exceptions import SupersetParseError
|
||||
|
||||
mock_query_context = Mock()
|
||||
mock_query_obj = Mock(spec=QueryObject)
|
||||
|
||||
parse_error = SupersetParseError(
|
||||
sql="SELECT * FROM table",
|
||||
message="Parsing error occurred",
|
||||
)
|
||||
# Mock error.extra to NOT have sql key
|
||||
parse_error.error.extra = {"other_field": "some_value"}
|
||||
|
||||
with patch("superset.common.query_actions._get_datasource") as mock_get_ds:
|
||||
mock_datasource = Mock()
|
||||
mock_datasource.query_language = "sql"
|
||||
mock_datasource.get_query_str.side_effect = parse_error
|
||||
mock_get_ds.return_value = mock_datasource
|
||||
|
||||
result = _get_query(mock_query_context, mock_query_obj, False)
|
||||
|
||||
assert "query" not in result
|
||||
assert result["error"] == "Parsing error occurred"
|
||||
assert result["language"] == "sql"
|
||||
|
||||
|
||||
def test_get_query_handles_parsing_error_with_null_sql_value() -> None:
|
||||
"""
|
||||
Test _get_query() when error.extra has 'sql': None explicitly set.
|
||||
|
||||
Edge case: error.extra = {"sql": None} with sql key present but value is None.
|
||||
Should NOT set result["query"] - prevents null from reaching TypeScript.
|
||||
Should still return error message for display.
|
||||
|
||||
Ensures defensive programming when extracting SQL from exception extra data.
|
||||
"""
|
||||
from superset.common.query_actions import _get_query
|
||||
from superset.common.query_object import QueryObject
|
||||
from superset.exceptions import SupersetParseError
|
||||
|
||||
mock_query_context = Mock()
|
||||
mock_query_obj = Mock(spec=QueryObject)
|
||||
|
||||
parse_error = SupersetParseError(
|
||||
sql="SELECT * FROM table",
|
||||
message="Parsing error occurred",
|
||||
)
|
||||
# Mock error.extra to have sql key with None value
|
||||
parse_error.error.extra = {"sql": None}
|
||||
|
||||
with patch("superset.common.query_actions._get_datasource") as mock_get_ds:
|
||||
mock_datasource = Mock()
|
||||
mock_datasource.query_language = "sql"
|
||||
mock_datasource.get_query_str.side_effect = parse_error
|
||||
mock_get_ds.return_value = mock_datasource
|
||||
|
||||
result = _get_query(mock_query_context, mock_query_obj, False)
|
||||
|
||||
assert "query" not in result
|
||||
assert result["error"] == "Parsing error occurred"
|
||||
assert result["language"] == "sql"
|
||||
@@ -482,7 +482,7 @@ class TestExecuteSql:
|
||||
}
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError, match="greater than or equal to 1"):
|
||||
with pytest.raises(ToolError, match="minimum of 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="less than or equal to 10000"):
|
||||
with pytest.raises(ToolError, match="maximum of 10000"):
|
||||
await client.call_tool("execute_sql", {"request": request})
|
||||
|
||||
@@ -1,55 +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.
|
||||
|
||||
"""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
|
||||
@@ -29,8 +29,6 @@ from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm.session import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from superset.superset_typing import AdhocColumn
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.models.core import Database
|
||||
|
||||
@@ -1127,34 +1125,3 @@ def test_process_select_expression_end_to_end(database: Database) -> None:
|
||||
assert expected.replace(" ", "").lower() in result.replace(" ", "").lower(), (
|
||||
f"Expected '{expected}' to be in result '{result}' for input '{expression}'"
|
||||
)
|
||||
|
||||
|
||||
def test_adhoc_column_to_sqla_with_column_reference(database: Database) -> None:
|
||||
"""
|
||||
Test that adhoc_column_to_sqla
|
||||
properly quotes column identifiers when isColumnReference is true.
|
||||
|
||||
This tests the fix for column names with spaces being properly quoted
|
||||
before being processed by SQLGlot to prevent "column AS alias" misinterpretation.
|
||||
"""
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
|
||||
table = SqlaTable(
|
||||
table_name="test_table",
|
||||
database=database,
|
||||
)
|
||||
|
||||
# Test 1: Column reference with spaces should be quoted
|
||||
col_with_spaces: AdhocColumn = {
|
||||
"sqlExpression": "Customer Name",
|
||||
"label": "Customer Name",
|
||||
"isColumnReference": True,
|
||||
}
|
||||
|
||||
result = table.adhoc_column_to_sqla(col_with_spaces)
|
||||
|
||||
# Should contain the quoted column name
|
||||
assert result is not None
|
||||
result_str = str(result)
|
||||
|
||||
assert '"Customer Name"' in result_str
|
||||
|
||||
@@ -271,15 +271,6 @@ 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,7 +23,6 @@ from PIL import Image
|
||||
|
||||
from superset.utils.screenshot_utils import (
|
||||
combine_screenshot_tiles,
|
||||
SCROLL_SETTLE_TIMEOUT_MS,
|
||||
take_tiled_screenshot,
|
||||
)
|
||||
|
||||
@@ -115,11 +114,22 @@ class TestTakeTiledScreenshot:
|
||||
element = MagicMock()
|
||||
page.locator.return_value = element
|
||||
|
||||
# Mock element info - simulating a 5000px tall dashboard at position 100
|
||||
# Mock element info - simulating a 5000px tall dashboard
|
||||
element_info = {"height": 5000, "top": 100, "left": 50, "width": 800}
|
||||
element_box = {"x": 50, "y": 200, "width": 800, "height": 600}
|
||||
|
||||
# Only one evaluate call needed for dashboard dimensions
|
||||
page.evaluate.return_value = element_info
|
||||
# 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
|
||||
]
|
||||
|
||||
# Mock screenshot method
|
||||
fake_screenshot = b"fake_screenshot_data"
|
||||
@@ -134,7 +144,7 @@ class TestTakeTiledScreenshot:
|
||||
) as mock_combine:
|
||||
mock_combine.return_value = b"combined_screenshot"
|
||||
|
||||
result = take_tiled_screenshot(mock_page, "dashboard", tile_height=2000)
|
||||
result = take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
|
||||
|
||||
# Should return combined screenshot
|
||||
assert result == b"combined_screenshot"
|
||||
@@ -153,7 +163,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", tile_height=2000)
|
||||
result = take_tiled_screenshot(mock_page, "nonexistent", viewport_height=2000)
|
||||
|
||||
assert result is None
|
||||
|
||||
@@ -161,25 +171,97 @@ 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}
|
||||
|
||||
# Override the fixture's evaluate return for this test
|
||||
mock_page.evaluate.return_value = element_info
|
||||
# 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
|
||||
]
|
||||
|
||||
with patch(
|
||||
"superset.utils.screenshot_utils.combine_screenshot_tiles"
|
||||
) as mock_combine:
|
||||
mock_combine.return_value = b"combined"
|
||||
|
||||
take_tiled_screenshot(mock_page, "dashboard", tile_height=2000)
|
||||
take_tiled_screenshot(mock_page, "dashboard", viewport_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", tile_height=2000)
|
||||
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
|
||||
|
||||
# Should log dashboard dimensions with lazy logging format
|
||||
mock_logger.info.assert_any_call(
|
||||
@@ -194,7 +276,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", tile_height=2000)
|
||||
result = take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
|
||||
|
||||
assert result is None
|
||||
# The exception object is passed, not the string
|
||||
@@ -202,44 +284,67 @@ 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", tile_height=2000)
|
||||
take_tiled_screenshot(mock_page, "dashboard", viewport_height=2000)
|
||||
|
||||
# Check screenshot calls have correct clip parameters
|
||||
screenshot_calls = mock_page.screenshot.call_args_list
|
||||
|
||||
# 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):
|
||||
for call in screenshot_calls:
|
||||
kwargs = call[1]
|
||||
assert kwargs["type"] == "png"
|
||||
assert kwargs["clip"]["x"] == 50
|
||||
assert kwargs["clip"]["width"] == 800
|
||||
assert "clip" in kwargs
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
# 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
|
||||
def test_skips_tiles_with_zero_height(self):
|
||||
"""Test that tiles with zero height are skipped."""
|
||||
mock_page = MagicMock()
|
||||
|
||||
# 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 locator
|
||||
element = MagicMock()
|
||||
mock_page.locator.return_value = element
|
||||
|
||||
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
|
||||
# Mock element info - 4000px tall dashboard
|
||||
element_info = {"height": 4000, "top": 100, "left": 50, "width": 800}
|
||||
mock_page.evaluate.return_value = element_info
|
||||
|
||||
# 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"
|
||||
|
||||
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
|
||||
with patch(
|
||||
@@ -247,76 +352,21 @@ class TestTakeTiledScreenshot:
|
||||
) as mock_combine:
|
||||
mock_combine.return_value = b"combined"
|
||||
|
||||
# Use exact viewport height that divides evenly
|
||||
result = take_tiled_screenshot(mock_page, "dashboard", tile_height=2000)
|
||||
result = take_tiled_screenshot(
|
||||
mock_page, "dashboard", viewport_height=2000
|
||||
)
|
||||
|
||||
# Should succeed
|
||||
# Should still succeed with partial tiles
|
||||
assert result == b"combined"
|
||||
|
||||
# Should take 2 screenshots (4000px / 2000px = 2)
|
||||
assert mock_page.screenshot.call_count == 2
|
||||
# Should only take 1 screenshot (second tile skipped)
|
||||
assert mock_page.screenshot.call_count == 1
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user