mirror of
https://github.com/apache/superset.git
synced 2026-06-18 14:09:16 +00:00
Compare commits
5 Commits
semantic-l
...
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 }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,7 +33,6 @@ cover
|
||||
.env
|
||||
.envrc
|
||||
.idea
|
||||
.roo
|
||||
.mypy_cache
|
||||
.python-version
|
||||
.tox
|
||||
|
||||
@@ -53,7 +53,7 @@ extension-pkg-whitelist=pyarrow
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
disable=all
|
||||
enable=json-import,disallowed-sql-import,consider-using-transaction
|
||||
enable=disallowed-json-import,disallowed-sql-import,consider-using-transaction
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
######################################################################
|
||||
# Node stage to deal with static asset construction
|
||||
######################################################################
|
||||
ARG PY_VER=3.11.14-slim-trixie
|
||||
ARG PY_VER=3.11.13-slim-trixie
|
||||
|
||||
# If BUILDPLATFORM is null, set it to 'amd64' (or leave as is otherwise).
|
||||
ARG BUILDPLATFORM=${BUILDPLATFORM:-amd64}
|
||||
|
||||
101
UPDATING.md
101
UPDATING.md
@@ -23,107 +23,6 @@ This file documents any backwards-incompatible changes in Superset and
|
||||
assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### MCP Service
|
||||
|
||||
The MCP (Model Context Protocol) service enables AI assistants and automation tools to interact programmatically with Superset.
|
||||
|
||||
#### New Features
|
||||
- MCP service infrastructure with FastMCP framework
|
||||
- Tools for dashboards, charts, datasets, SQL Lab, and instance metadata
|
||||
- Optional dependency: install with `pip install apache-superset[fastmcp]`
|
||||
- Runs as separate process from Superset web server
|
||||
- JWT-based authentication for production deployments
|
||||
|
||||
#### New Configuration Options
|
||||
|
||||
**Development** (single-user, local testing):
|
||||
```python
|
||||
# superset_config.py
|
||||
MCP_DEV_USERNAME = "admin" # User for MCP authentication
|
||||
MCP_SERVICE_HOST = "localhost"
|
||||
MCP_SERVICE_PORT = 5008
|
||||
```
|
||||
|
||||
**Production** (JWT-based, multi-user):
|
||||
```python
|
||||
# superset_config.py
|
||||
MCP_AUTH_ENABLED = True
|
||||
MCP_JWT_ISSUER = "https://your-auth-provider.com"
|
||||
MCP_JWT_AUDIENCE = "superset-mcp"
|
||||
MCP_JWT_ALGORITHM = "RS256" # or "HS256" for shared secrets
|
||||
|
||||
# Option 1: Use JWKS endpoint (recommended for RS256)
|
||||
MCP_JWKS_URI = "https://auth.example.com/.well-known/jwks.json"
|
||||
|
||||
# Option 2: Use static public key (RS256)
|
||||
MCP_JWT_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----..."
|
||||
|
||||
# Option 3: Use shared secret (HS256)
|
||||
MCP_JWT_ALGORITHM = "HS256"
|
||||
MCP_JWT_SECRET = "your-shared-secret-key"
|
||||
|
||||
# Optional overrides
|
||||
MCP_SERVICE_HOST = "0.0.0.0"
|
||||
MCP_SERVICE_PORT = 5008
|
||||
MCP_SESSION_CONFIG = {
|
||||
"SESSION_COOKIE_SECURE": True,
|
||||
"SESSION_COOKIE_HTTPONLY": True,
|
||||
"SESSION_COOKIE_SAMESITE": "Strict",
|
||||
}
|
||||
```
|
||||
|
||||
#### Running the MCP Service
|
||||
|
||||
```bash
|
||||
# Development
|
||||
superset mcp run --port 5008 --debug
|
||||
|
||||
# Production
|
||||
superset mcp run --port 5008
|
||||
|
||||
# With factory config
|
||||
superset mcp run --port 5008 --use-factory-config
|
||||
```
|
||||
|
||||
#### Deployment Considerations
|
||||
|
||||
The MCP service runs as a **separate process** from the Superset web server.
|
||||
|
||||
**Important**:
|
||||
- Requires same Python environment and configuration as Superset
|
||||
- Shares database connections with main Superset app
|
||||
- Can be scaled independently from web server
|
||||
- Requires `fastmcp` package (optional dependency)
|
||||
|
||||
**Installation**:
|
||||
```bash
|
||||
# Install with MCP support
|
||||
pip install apache-superset[fastmcp]
|
||||
|
||||
# Or add to requirements.txt
|
||||
apache-superset[fastmcp]>=X.Y.Z
|
||||
```
|
||||
|
||||
**Process Management**:
|
||||
Use systemd, supervisord, or Kubernetes to manage the MCP service process.
|
||||
See `superset/mcp_service/PRODUCTION.md` for deployment guides.
|
||||
|
||||
**Security**:
|
||||
- Development: Uses `MCP_DEV_USERNAME` for single-user access
|
||||
- Production: **MUST** configure JWT authentication
|
||||
- See `superset/mcp_service/SECURITY.md` for details
|
||||
|
||||
#### Documentation
|
||||
|
||||
- Architecture: `superset/mcp_service/ARCHITECTURE.md`
|
||||
- Security: `superset/mcp_service/SECURITY.md`
|
||||
- Production: `superset/mcp_service/PRODUCTION.md`
|
||||
- Developer Guide: `superset/mcp_service/CLAUDE.md`
|
||||
- Quick Start: `superset/mcp_service/README.md`
|
||||
|
||||
---
|
||||
|
||||
- [33055](https://github.com/apache/superset/pull/33055): Upgrades Flask-AppBuilder to 5.0.0. The AUTH_OID authentication type has been deprecated and is no longer available as an option in Flask-AppBuilder. OpenID (OID) is considered a deprecated authentication protocol - if you are using AUTH_OID, you will need to migrate to an alternative authentication method such as OAuth, LDAP, or database authentication before upgrading.
|
||||
- [35062](https://github.com/apache/superset/pull/35062): Changed the function signature of `setupExtensions` to `setupCodeOverrides` with options as arguments.
|
||||
- [34871](https://github.com/apache/superset/pull/34871): Fixed Jest test hanging issue from Ant Design v5 upgrade. MessageChannel is now mocked in test environment to prevent rc-overflow from causing Jest to hang. Test environment only - no production impact.
|
||||
|
||||
@@ -67,7 +67,6 @@ x-superset-volumes: &superset-volumes
|
||||
- ./superset-frontend:/app/superset-frontend
|
||||
- superset_home_light:/app/superset_home
|
||||
- ./tests:/app/tests
|
||||
- ./extensions:/app/extensions
|
||||
x-common-build: &common-build
|
||||
context: .
|
||||
target: ${SUPERSET_BUILD_TARGET:-dev} # can use `dev` (default) or `lean`
|
||||
|
||||
@@ -80,7 +80,7 @@ case "${1}" in
|
||||
;;
|
||||
app)
|
||||
echo "Starting web app (using development server)..."
|
||||
flask run -p $PORT --reload --debugger --without-threads --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*"
|
||||
flask run -p $PORT --reload --debugger --without-threads --host=0.0.0.0
|
||||
;;
|
||||
app-gunicorn)
|
||||
echo "Starting web app..."
|
||||
|
||||
@@ -105,15 +105,7 @@ class CeleryConfig:
|
||||
|
||||
CELERY_CONFIG = CeleryConfig
|
||||
|
||||
# Extensions configuration
|
||||
# For local development, point to the extensions directory
|
||||
# Note: If running in Docker, this path needs to be accessible from inside the container
|
||||
EXTENSIONS_PATH = os.getenv("EXTENSIONS_PATH", "/app/extensions")
|
||||
|
||||
FEATURE_FLAGS = {
|
||||
"ALERT_REPORTS": True,
|
||||
"ENABLE_EXTENSIONS": True,
|
||||
}
|
||||
FEATURE_FLAGS = {"ALERT_REPORTS": True}
|
||||
ALERT_REPORTS_NOTIFICATION_DRY_RUN = True
|
||||
WEBDRIVER_BASEURL = f"http://superset_app{os.environ.get('SUPERSET_APP_ROOT', '/')}/" # When using docker compose baseurl should be http://superset_nginx{ENV{BASEPATH}}/ # noqa: E501
|
||||
# The base URL for the email report hyperlinks.
|
||||
|
||||
@@ -26,8 +26,6 @@ under the License.
|
||||
|
||||
Extensions interact with Superset through well-defined, versioned APIs provided by the `@apache-superset/core` (frontend) and `apache-superset-core` (backend) packages. These APIs are designed to be stable, discoverable, and consistent for both built-in and external extensions.
|
||||
|
||||
**Note**: The `superset_core.api` module provides abstract classes that are replaced with concrete implementations via dependency injection when Superset initializes. This allows extensions to use the same interfaces as the host application.
|
||||
|
||||
**Frontend APIs** (via `@apache-superset/core)`:
|
||||
|
||||
The frontend extension APIs in Superset are organized into logical namespaces such as `authentication`, `commands`, `extensions`, `sqlLab`, and others. Each namespace groups related functionality, making it easy for extension authors to discover and use the APIs relevant to their needs. For example, the `sqlLab` namespace provides events and methods specific to SQL Lab, allowing extensions to react to user actions and interact with the SQL Lab environment:
|
||||
@@ -92,38 +90,31 @@ Backend APIs follow a similar pattern, providing access to Superset's models, se
|
||||
Extension endpoints are registered under a dedicated `/extensions` namespace to avoid conflicting with built-in endpoints and also because they don't share the same version constraints. By grouping all extension endpoints under `/extensions`, Superset establishes a clear boundary between core and extension functionality, making it easier to manage, document, and secure both types of APIs.
|
||||
|
||||
``` python
|
||||
from superset_core.api.models import Database, get_session
|
||||
from superset_core.api.daos import DatabaseDAO
|
||||
from superset_core.api.rest_api import add_extension_api
|
||||
from superset_core.api import rest_api, models, query
|
||||
from .api import DatasetReferencesAPI
|
||||
|
||||
# Register a new extension REST API
|
||||
add_extension_api(DatasetReferencesAPI)
|
||||
rest_api.add_extension_api(DatasetReferencesAPI)
|
||||
|
||||
# Fetch Superset entities via the DAO to apply base filters that filter out entities
|
||||
# that the user doesn't have access to
|
||||
databases = DatabaseDAO.find_all()
|
||||
|
||||
# ..or apply simple filters on top of base filters
|
||||
databases = DatabaseDAO.filter_by(uuid=database.uuid)
|
||||
# Access Superset models with simple queries that filter out entities that
|
||||
# the user doesn't have access to
|
||||
databases = models.get_databases(id=database_id)
|
||||
if not databases:
|
||||
raise Exception("Database not found")
|
||||
return self.response_404()
|
||||
|
||||
return databases[0]
|
||||
database = databases[0]
|
||||
|
||||
# Perform complex queries using SQLAlchemy Query, also filtering out
|
||||
# inaccessible entities
|
||||
session = get_session()
|
||||
databases_query = session.query(Database).filter(
|
||||
Database.database_name.ilike("%abc%")
|
||||
)
|
||||
return DatabaseDAO.query(databases_query)
|
||||
# Perform complex queries using SQLAlchemy BaseQuery, also filtering
|
||||
# out inaccessible entities
|
||||
session = models.get_session()
|
||||
db_model = models.get_database_model())
|
||||
database_query = session.query(db_model.database_name.ilike("%abc%")
|
||||
databases_containing_abc = models.get_databases(query)
|
||||
|
||||
# Bypass security model for highly custom use cases
|
||||
session = get_session()
|
||||
all_databases_containing_abc = session.query(Database).filter(
|
||||
Database.database_name.ilike("%abc%")
|
||||
).all()
|
||||
session = models.get_session()
|
||||
db_model = models.get_database_model())
|
||||
all_databases_containg_abc = session.query(db_model.database_name.ilike("%abc%").all()
|
||||
```
|
||||
|
||||
In the future, we plan to expand the backend APIs to support configuring security models, database engines, SQL Alchemy dialects, etc.
|
||||
|
||||
@@ -128,7 +128,7 @@ The CLI generated a basic `backend/src/hello_world/entrypoint.py`. We'll create
|
||||
```python
|
||||
from flask import Response
|
||||
from flask_appbuilder.api import expose, protect, safe
|
||||
from superset_core.api.rest_api import RestApi
|
||||
from superset_core.api.types.rest_api import RestApi
|
||||
|
||||
|
||||
class HelloWorldAPI(RestApi):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,101 +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.
|
||||
-->
|
||||
|
||||
# pkg_resources Deprecation and Migration Guide
|
||||
|
||||
## Background
|
||||
|
||||
As of setuptools 81.0.0 (scheduled for removal around 2025-11-30), the `pkg_resources` API is deprecated and will be removed. This affects several packages in the Python ecosystem.
|
||||
|
||||
## Current Status
|
||||
|
||||
### Superset Codebase ✅
|
||||
The Superset codebase has already migrated away from `pkg_resources` to the modern `importlib.metadata` API:
|
||||
|
||||
- `superset/db_engine_specs/__init__.py:36` - Uses `from importlib.metadata import entry_points`
|
||||
- All entry point loading uses the modern API
|
||||
|
||||
### Production Dependencies ⚠️
|
||||
Some third-party dependencies may still use `pkg_resources`:
|
||||
|
||||
- **`clients` package** (Preset-specific): Uses `pkg_resources` in `const.py`
|
||||
- Error path: `/usr/local/lib/python3.10/site-packages/clients/const.py:1`
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Short-term Solution (Current)
|
||||
Pin setuptools to version 80.x to prevent breaking changes:
|
||||
|
||||
```python
|
||||
# requirements/base.in
|
||||
setuptools<81
|
||||
```
|
||||
|
||||
This prevents the removal of `pkg_resources` while dependent packages are updated.
|
||||
|
||||
### Long-term Solution
|
||||
Update all dependencies to use `importlib.metadata` instead of `pkg_resources`:
|
||||
|
||||
#### Migration Example
|
||||
**Old (deprecated):**
|
||||
```python
|
||||
import pkg_resources
|
||||
|
||||
version = pkg_resources.get_distribution("package_name").version
|
||||
entry_points = pkg_resources.iter_entry_points("group_name")
|
||||
```
|
||||
|
||||
**New (recommended):**
|
||||
```python
|
||||
from importlib.metadata import version, entry_points
|
||||
|
||||
pkg_version = version("package_name")
|
||||
eps = entry_points(group="group_name")
|
||||
```
|
||||
|
||||
## Action Items
|
||||
|
||||
### For Preset Team
|
||||
1. **Update `clients` package** to use `importlib.metadata` instead of `pkg_resources`
|
||||
2. **Review other internal packages** for `pkg_resources` usage
|
||||
3. **Test with setuptools >= 81.0.0** once all packages are migrated
|
||||
4. **Monitor Datadog logs** for similar deprecation warnings
|
||||
|
||||
### For Superset Maintainers
|
||||
1. ✅ Already using `importlib.metadata`
|
||||
2. Monitor third-party dependencies for updates
|
||||
3. Update setuptools pin once ecosystem is ready
|
||||
|
||||
## Timeline
|
||||
|
||||
- **2025-11-30**: Expected removal of `pkg_resources` from setuptools
|
||||
- **Before then**: All dependencies must migrate to `importlib.metadata`
|
||||
|
||||
## References
|
||||
|
||||
- [setuptools pkg_resources deprecation notice](https://setuptools.pypa.io/en/latest/pkg_resources.html)
|
||||
- [importlib.metadata documentation](https://docs.python.org/3/library/importlib.metadata.html)
|
||||
- [Migration guide](https://setuptools.pypa.io/en/latest/deprecated/pkg_resources.html)
|
||||
|
||||
## Monitoring
|
||||
|
||||
Track this issue in production using Datadog:
|
||||
- Warning pattern: `pkg_resources is deprecated as an API`
|
||||
- Component: `@component:app`
|
||||
- Environment: `environment:production`
|
||||
@@ -487,7 +487,7 @@ const config: Config = {
|
||||
'data-project-name': 'Apache Superset',
|
||||
'data-project-color': '#FFFFFF',
|
||||
'data-project-logo':
|
||||
'https://superset.apache.org/img/superset-logo-icon-only.png',
|
||||
'https://images.seeklogo.com/logo-png/50/2/superset-icon-logo-png_seeklogo-500354.png',
|
||||
'data-modal-override-open-id': 'ask-ai-input',
|
||||
'data-modal-override-open-class': 'search-input',
|
||||
'data-modal-disclaimer':
|
||||
|
||||
@@ -49,8 +49,8 @@
|
||||
"@storybook/preview-api": "^8.6.11",
|
||||
"@storybook/theming": "^8.6.11",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"antd": "^5.29.1",
|
||||
"caniuse-lite": "^1.0.30001756",
|
||||
"antd": "^5.28.0",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
"docusaurus-plugin-less": "^2.0.2",
|
||||
"json-bigint": "^1.0.0",
|
||||
"less": "^4.4.2",
|
||||
@@ -70,19 +70,19 @@
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.9.1",
|
||||
"@docusaurus/tsconfig": "^3.9.2",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@eslint/js": "^9.39.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.37.0",
|
||||
"@typescript-eslint/parser": "^8.46.4",
|
||||
"eslint": "^9.39.1",
|
||||
"@typescript-eslint/parser": "^8.46.0",
|
||||
"eslint": "^9.39.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.3",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^16.5.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.47.0",
|
||||
"webpack": "^5.103.0"
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"webpack": "^5.102.1"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
BIN
docs/static/img/superset-logo-icon-only.png
vendored
BIN
docs/static/img/superset-logo-icon-only.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 116 KiB |
201
docs/yarn.lock
201
docs/yarn.lock
@@ -1160,7 +1160,12 @@
|
||||
dependencies:
|
||||
core-js-pure "^3.43.0"
|
||||
|
||||
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.1", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.4", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.7", "@babel/runtime@^7.18.0", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.22.5", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.6", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.4", "@babel/runtime@^7.24.7", "@babel/runtime@^7.24.8", "@babel/runtime@^7.25.6", "@babel/runtime@^7.25.7", "@babel/runtime@^7.25.9", "@babel/runtime@^7.26.0", "@babel/runtime@^7.28.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2":
|
||||
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.1", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.4", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.7", "@babel/runtime@^7.18.0", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.22.5", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.6", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.4", "@babel/runtime@^7.24.7", "@babel/runtime@^7.24.8", "@babel/runtime@^7.25.6", "@babel/runtime@^7.25.7", "@babel/runtime@^7.25.9", "@babel/runtime@^7.26.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2":
|
||||
version "7.28.3"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.3.tgz#75c5034b55ba868121668be5d5bb31cc64e6e61a"
|
||||
integrity sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==
|
||||
|
||||
"@babel/runtime@^7.28.4":
|
||||
version "7.28.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326"
|
||||
integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==
|
||||
@@ -2466,10 +2471,10 @@
|
||||
minimatch "^3.1.2"
|
||||
strip-json-comments "^3.1.1"
|
||||
|
||||
"@eslint/js@9.39.1", "@eslint/js@^9.39.1":
|
||||
version "9.39.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.1.tgz#0dd59c3a9f40e3f1882975c321470969243e0164"
|
||||
integrity sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==
|
||||
"@eslint/js@9.39.0", "@eslint/js@^9.39.0":
|
||||
version "9.39.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.0.tgz#e1955cefd1d79e80a9557274e9aa9bd3f641be01"
|
||||
integrity sha512-BIhe0sW91JGPiaF1mOuPy5v8NflqfjIcDNpC+LbW9f609WVRX1rArrhi6Z2ymvrAry9jw+5POTj4t2t62o8Bmw==
|
||||
|
||||
"@eslint/object-schema@^2.1.7":
|
||||
version "2.1.7"
|
||||
@@ -4192,79 +4197,79 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.47.0", "@typescript-eslint/eslint-plugin@^8.37.0":
|
||||
version "8.47.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz#c53edeec13a79483f4ca79c298d5231b02e9dc17"
|
||||
integrity sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==
|
||||
"@typescript-eslint/eslint-plugin@8.46.2", "@typescript-eslint/eslint-plugin@^8.37.0":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz#dc4ab93ee3d7e6c8e38820a0d6c7c93c7183e2dc"
|
||||
integrity sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.10.0"
|
||||
"@typescript-eslint/scope-manager" "8.47.0"
|
||||
"@typescript-eslint/type-utils" "8.47.0"
|
||||
"@typescript-eslint/utils" "8.47.0"
|
||||
"@typescript-eslint/visitor-keys" "8.47.0"
|
||||
"@typescript-eslint/scope-manager" "8.46.2"
|
||||
"@typescript-eslint/type-utils" "8.46.2"
|
||||
"@typescript-eslint/utils" "8.46.2"
|
||||
"@typescript-eslint/visitor-keys" "8.46.2"
|
||||
graphemer "^1.4.0"
|
||||
ignore "^7.0.0"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.1.0"
|
||||
|
||||
"@typescript-eslint/parser@8.47.0", "@typescript-eslint/parser@^8.46.4":
|
||||
version "8.47.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.47.0.tgz#51b14ab2be2057ec0f57073b9ff3a9c078b0a964"
|
||||
integrity sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==
|
||||
"@typescript-eslint/parser@8.46.2", "@typescript-eslint/parser@^8.46.0":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.46.2.tgz#dd938d45d581ac8ffa9d8a418a50282b306f7ebf"
|
||||
integrity sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.47.0"
|
||||
"@typescript-eslint/types" "8.47.0"
|
||||
"@typescript-eslint/typescript-estree" "8.47.0"
|
||||
"@typescript-eslint/visitor-keys" "8.47.0"
|
||||
"@typescript-eslint/scope-manager" "8.46.2"
|
||||
"@typescript-eslint/types" "8.46.2"
|
||||
"@typescript-eslint/typescript-estree" "8.46.2"
|
||||
"@typescript-eslint/visitor-keys" "8.46.2"
|
||||
debug "^4.3.4"
|
||||
|
||||
"@typescript-eslint/project-service@8.47.0":
|
||||
version "8.47.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.47.0.tgz#b8afc65e0527568018af911b702dcfbfdca16471"
|
||||
integrity sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==
|
||||
"@typescript-eslint/project-service@8.46.2":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.46.2.tgz#ab2f02a0de4da6a7eeb885af5e059be57819d608"
|
||||
integrity sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.47.0"
|
||||
"@typescript-eslint/types" "^8.47.0"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.46.2"
|
||||
"@typescript-eslint/types" "^8.46.2"
|
||||
debug "^4.3.4"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.47.0":
|
||||
version "8.47.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz#d1c36a973a5499fed3a99e2e6a66aec5c9b1e542"
|
||||
integrity sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==
|
||||
"@typescript-eslint/scope-manager@8.46.2":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz#7d37df2493c404450589acb3b5d0c69cc0670a88"
|
||||
integrity sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.47.0"
|
||||
"@typescript-eslint/visitor-keys" "8.47.0"
|
||||
"@typescript-eslint/types" "8.46.2"
|
||||
"@typescript-eslint/visitor-keys" "8.46.2"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.47.0", "@typescript-eslint/tsconfig-utils@^8.47.0":
|
||||
version "8.47.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz#4f178b62813538759e0989dd081c5474fad39b84"
|
||||
integrity sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==
|
||||
"@typescript-eslint/tsconfig-utils@8.46.2", "@typescript-eslint/tsconfig-utils@^8.46.2":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz#d110451cb93bbd189865206ea37ef677c196828c"
|
||||
integrity sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==
|
||||
|
||||
"@typescript-eslint/type-utils@8.47.0":
|
||||
version "8.47.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz#b9b0141d99bd5bece3811d7eee68a002597ffa55"
|
||||
integrity sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==
|
||||
"@typescript-eslint/type-utils@8.46.2":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz#802d027864e6fb752e65425ed09f3e089fb4d384"
|
||||
integrity sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.47.0"
|
||||
"@typescript-eslint/typescript-estree" "8.47.0"
|
||||
"@typescript-eslint/utils" "8.47.0"
|
||||
"@typescript-eslint/types" "8.46.2"
|
||||
"@typescript-eslint/typescript-estree" "8.46.2"
|
||||
"@typescript-eslint/utils" "8.46.2"
|
||||
debug "^4.3.4"
|
||||
ts-api-utils "^2.1.0"
|
||||
|
||||
"@typescript-eslint/types@8.47.0", "@typescript-eslint/types@^8.47.0":
|
||||
version "8.47.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.47.0.tgz#c7fc9b6642d03505f447a8392934b9d1850de5af"
|
||||
integrity sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==
|
||||
"@typescript-eslint/types@8.46.2", "@typescript-eslint/types@^8.46.2":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.2.tgz#2bad7348511b31e6e42579820e62b73145635763"
|
||||
integrity sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.47.0":
|
||||
version "8.47.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz#86416dad58db76c4b3bd6a899b1381f9c388489a"
|
||||
integrity sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==
|
||||
"@typescript-eslint/typescript-estree@8.46.2":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz#ab547a27e4222bb6a3281cb7e98705272e2c7d08"
|
||||
integrity sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.47.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.47.0"
|
||||
"@typescript-eslint/types" "8.47.0"
|
||||
"@typescript-eslint/visitor-keys" "8.47.0"
|
||||
"@typescript-eslint/project-service" "8.46.2"
|
||||
"@typescript-eslint/tsconfig-utils" "8.46.2"
|
||||
"@typescript-eslint/types" "8.46.2"
|
||||
"@typescript-eslint/visitor-keys" "8.46.2"
|
||||
debug "^4.3.4"
|
||||
fast-glob "^3.3.2"
|
||||
is-glob "^4.0.3"
|
||||
@@ -4272,22 +4277,22 @@
|
||||
semver "^7.6.0"
|
||||
ts-api-utils "^2.1.0"
|
||||
|
||||
"@typescript-eslint/utils@8.47.0":
|
||||
version "8.47.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.47.0.tgz#d6c30690431dbfdab98fc027202af12e77c91419"
|
||||
integrity sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==
|
||||
"@typescript-eslint/utils@8.46.2":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.46.2.tgz#b313d33d67f9918583af205bd7bcebf20f231732"
|
||||
integrity sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.7.0"
|
||||
"@typescript-eslint/scope-manager" "8.47.0"
|
||||
"@typescript-eslint/types" "8.47.0"
|
||||
"@typescript-eslint/typescript-estree" "8.47.0"
|
||||
"@typescript-eslint/scope-manager" "8.46.2"
|
||||
"@typescript-eslint/types" "8.46.2"
|
||||
"@typescript-eslint/typescript-estree" "8.46.2"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.47.0":
|
||||
version "8.47.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz#35f36ed60a170dfc9d4d738e78387e217f24c29f"
|
||||
integrity sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==
|
||||
"@typescript-eslint/visitor-keys@8.46.2":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz#803fa298948c39acf810af21bdce6f8babfa9738"
|
||||
integrity sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.47.0"
|
||||
"@typescript-eslint/types" "8.46.2"
|
||||
eslint-visitor-keys "^4.2.1"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
@@ -4602,10 +4607,10 @@ ansi-styles@^6.1.0:
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
|
||||
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
|
||||
|
||||
antd@^5.29.1:
|
||||
version "5.29.1"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-5.29.1.tgz#e124b1aaa709a534816c42d558da02a917b995cc"
|
||||
integrity sha512-TTFVbpKbyL6cPfEoKq6Ya3BIjTUr7uDW9+7Z+1oysRv1gpcN7kQ4luH8r/+rXXwz4n6BIz1iBJ1ezKCdsdNW0w==
|
||||
antd@^5.28.0:
|
||||
version "5.28.0"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-5.28.0.tgz#fb5dfc0a2ba5a90ee053c813d71f16e6b66ac994"
|
||||
integrity sha512-AmCvyhWGHzlDQ6sfnGBBrFm/8sLPbBI8d/NDBsecliKqrTZUMr07TAQldo43iowwKzvgKxxuRoUHaBaYcBMdQA==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^7.2.1"
|
||||
"@ant-design/cssinjs" "^1.23.0"
|
||||
@@ -5161,10 +5166,10 @@ caniuse-api@^3.0.0:
|
||||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001746, caniuse-lite@^1.0.30001756:
|
||||
version "1.0.30001756"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz#fe80104631102f88e58cad8aa203a2c3e5ec9ebd"
|
||||
integrity sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001746, caniuse-lite@^1.0.30001754:
|
||||
version "1.0.30001754"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz#7758299d9a72cce4e6b038788a15b12b44002759"
|
||||
integrity sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
@@ -6833,10 +6838,10 @@ eslint-visitor-keys@^4.2.1:
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
|
||||
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
|
||||
|
||||
eslint@^9.39.1:
|
||||
version "9.39.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.1.tgz#be8bf7c6de77dcc4252b5a8dcb31c2efff74a6e5"
|
||||
integrity sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==
|
||||
eslint@^9.39.0:
|
||||
version "9.39.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.0.tgz#33c90ddf62b64e1e3f83b689934b336f21b5f0e5"
|
||||
integrity sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.8.0"
|
||||
"@eslint-community/regexpp" "^4.12.1"
|
||||
@@ -6844,7 +6849,7 @@ eslint@^9.39.1:
|
||||
"@eslint/config-helpers" "^0.4.2"
|
||||
"@eslint/core" "^0.17.0"
|
||||
"@eslint/eslintrc" "^3.3.1"
|
||||
"@eslint/js" "9.39.1"
|
||||
"@eslint/js" "9.39.0"
|
||||
"@eslint/plugin-kit" "^0.4.1"
|
||||
"@humanfs/node" "^0.16.6"
|
||||
"@humanwhocodes/module-importer" "^1.0.1"
|
||||
@@ -8716,10 +8721,10 @@ lines-and-columns@^1.1.6:
|
||||
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
|
||||
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
|
||||
|
||||
loader-runner@^4.3.1:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.1.tgz#6c76ed29b0ccce9af379208299f07f876de737e3"
|
||||
integrity sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==
|
||||
loader-runner@^4.2.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1"
|
||||
integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==
|
||||
|
||||
loader-utils@^2.0.0:
|
||||
version "2.0.4"
|
||||
@@ -13358,15 +13363,15 @@ types-ramda@^0.30.1:
|
||||
dependencies:
|
||||
ts-toolbelt "^9.6.0"
|
||||
|
||||
typescript-eslint@^8.47.0:
|
||||
version "8.47.0"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.47.0.tgz#bb8fcf4f2c69ffcd5d088f7f30cd52936ff05cbc"
|
||||
integrity sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q==
|
||||
typescript-eslint@^8.46.2:
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.46.2.tgz#da1adec683ba93a1b6c3850a4efb0922ffbc627d"
|
||||
integrity sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin" "8.47.0"
|
||||
"@typescript-eslint/parser" "8.47.0"
|
||||
"@typescript-eslint/typescript-estree" "8.47.0"
|
||||
"@typescript-eslint/utils" "8.47.0"
|
||||
"@typescript-eslint/eslint-plugin" "8.46.2"
|
||||
"@typescript-eslint/parser" "8.46.2"
|
||||
"@typescript-eslint/typescript-estree" "8.46.2"
|
||||
"@typescript-eslint/utils" "8.46.2"
|
||||
|
||||
typescript@~5.9.3:
|
||||
version "5.9.3"
|
||||
@@ -13904,10 +13909,10 @@ webpack-virtual-modules@^0.6.2:
|
||||
resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz#057faa9065c8acf48f24cb57ac0e77739ab9a7e8"
|
||||
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
|
||||
|
||||
webpack@^5.103.0, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.103.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.103.0.tgz#17a7c5a5020d5a3a37c118d002eade5ee2c6f3da"
|
||||
integrity sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==
|
||||
webpack@^5.102.1, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.102.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.102.1.tgz#1003a3024741a96ba99c37431938bf61aad3d988"
|
||||
integrity sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==
|
||||
dependencies:
|
||||
"@types/eslint-scope" "^3.7.7"
|
||||
"@types/estree" "^1.0.8"
|
||||
@@ -13926,7 +13931,7 @@ webpack@^5.103.0, webpack@^5.88.1, webpack@^5.95.0:
|
||||
glob-to-regexp "^0.4.1"
|
||||
graceful-fs "^4.2.11"
|
||||
json-parse-even-better-errors "^2.3.1"
|
||||
loader-runner "^4.3.1"
|
||||
loader-runner "^4.2.0"
|
||||
mime-types "^2.1.27"
|
||||
neo-async "^2.6.2"
|
||||
schema-utils "^4.3.3"
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Requirements for the Snowflake Semantic Layer extension
|
||||
# Install with: pip install -r extensions/requirements-snowflake.txt
|
||||
|
||||
snowflake-connector-python>=3.0.0
|
||||
snowflake-sqlalchemy>=1.5.0
|
||||
Binary file not shown.
@@ -48,7 +48,7 @@ dependencies = [
|
||||
"cryptography>=42.0.4, <45.0.0",
|
||||
"deprecation>=2.1.0, <2.2.0",
|
||||
"flask>=2.2.5, <3.0.0",
|
||||
"flask-appbuilder>=5.0.2,<6",
|
||||
"flask-appbuilder>=5.0.0,<6",
|
||||
"flask-caching>=2.1.0, <3",
|
||||
"flask-compress>=1.13, <2.0",
|
||||
"flask-talisman>=1.0.0, <2.0",
|
||||
@@ -133,13 +133,14 @@ denodo = ["denodo-sqlalchemy~=1.0.6"]
|
||||
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
|
||||
drill = ["sqlalchemy-drill>=1.1.4, <2"]
|
||||
druid = ["pydruid>=0.6.5,<0.7"]
|
||||
duckdb = ["duckdb>=1.4.2,<2", "duckdb-engine>=0.17.0"]
|
||||
# DuckDB 1.x has type system incompatibilities with duckdb-engine.
|
||||
duckdb = ["duckdb>=0.10.2,<0.11", "duckdb-engine>=0.17.0"]
|
||||
dynamodb = ["pydynamodb>=0.4.2"]
|
||||
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"]
|
||||
|
||||
@@ -36,9 +36,3 @@ marshmallow-sqlalchemy>=1.3.0,<1.4.1
|
||||
|
||||
# needed for python 3.12 support
|
||||
openapi-schema-validator>=0.6.3
|
||||
|
||||
# Pin setuptools <81 until all dependencies migrate from pkg_resources to importlib.metadata
|
||||
# pkg_resources is deprecated and will be removed in setuptools 81+ (around 2025-11-30)
|
||||
# Known affected packages: Preset's 'clients' package
|
||||
# See docs/docs/contributing/pkg-resources-migration.md for details
|
||||
setuptools<81
|
||||
|
||||
@@ -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
|
||||
@@ -116,7 +116,7 @@ flask==2.3.3
|
||||
# flask-session
|
||||
# flask-sqlalchemy
|
||||
# flask-wtf
|
||||
flask-appbuilder==5.0.2
|
||||
flask-appbuilder==5.0.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
@@ -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
|
||||
@@ -364,8 +363,6 @@ rsa==4.9.1
|
||||
# via google-auth
|
||||
selenium==4.32.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
setuptools==80.9.0
|
||||
# via -r requirements/base.in
|
||||
shillelagh==1.4.3
|
||||
# via apache-superset (pyproject.toml)
|
||||
simplejson==3.20.1
|
||||
@@ -386,7 +383,6 @@ sqlalchemy==1.4.54
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# alembic
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
# flask-sqlalchemy
|
||||
# marshmallow-sqlalchemy
|
||||
@@ -395,12 +391,9 @@ sqlalchemy==1.4.54
|
||||
sqlalchemy-utils==0.38.3
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
sqlglot==27.15.2
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
# via apache-superset (pyproject.toml)
|
||||
sshtunnel==0.4.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
tabulate==0.9.0
|
||||
@@ -411,11 +404,10 @@ 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
|
||||
# apache-superset-core
|
||||
# cattrs
|
||||
# limits
|
||||
# pydantic
|
||||
|
||||
@@ -48,7 +48,7 @@ attrs==25.3.0
|
||||
# referencing
|
||||
# requests-cache
|
||||
# trio
|
||||
authlib==1.6.5
|
||||
authlib==1.6.4
|
||||
# via fastmcp
|
||||
babel==2.17.0
|
||||
# via
|
||||
@@ -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,12 +167,10 @@ cryptography==44.0.3
|
||||
# apache-superset
|
||||
# authlib
|
||||
# paramiko
|
||||
# pyjwt
|
||||
# pyopenssl
|
||||
# secretstorage
|
||||
cycler==0.12.1
|
||||
# via matplotlib
|
||||
cyclopts==4.2.4
|
||||
cyclopts==3.24.0
|
||||
# via fastmcp
|
||||
db-dtypes==1.3.1
|
||||
# via pandas-gbq
|
||||
@@ -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
|
||||
@@ -211,7 +200,7 @@ docstring-parser==0.17.0
|
||||
# via cyclopts
|
||||
docutils==0.22.2
|
||||
# via rich-rst
|
||||
duckdb==1.4.2
|
||||
duckdb==0.10.3
|
||||
# via
|
||||
# apache-superset
|
||||
# duckdb-engine
|
||||
@@ -228,7 +217,7 @@ et-xmlfile==2.0.0
|
||||
# openpyxl
|
||||
exceptiongroup==1.3.0
|
||||
# via fastmcp
|
||||
fastmcp==2.13.1
|
||||
fastmcp==2.10.6
|
||||
# via apache-superset
|
||||
filelock==3.12.2
|
||||
# via virtualenv
|
||||
@@ -249,7 +238,7 @@ flask==2.3.3
|
||||
# flask-sqlalchemy
|
||||
# flask-testing
|
||||
# flask-wtf
|
||||
flask-appbuilder==5.0.2
|
||||
flask-appbuilder==5.0.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -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,17 +844,14 @@ rsa==4.9.1
|
||||
# google-auth
|
||||
ruff==0.8.0
|
||||
# via apache-superset
|
||||
secretstorage==3.4.1
|
||||
# via keyring
|
||||
selenium==4.32.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
semver==3.0.4
|
||||
# via apache-superset-extensions-cli
|
||||
setuptools==80.9.0
|
||||
setuptools==80.7.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# nodeenv
|
||||
# pandas-gbq
|
||||
# pydata-google-auth
|
||||
@@ -933,7 +890,6 @@ sqlalchemy==1.4.54
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
# duckdb-engine
|
||||
# flask-appbuilder
|
||||
# flask-sqlalchemy
|
||||
@@ -947,13 +903,11 @@ sqlalchemy-utils==0.38.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
sqlglot==27.15.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
sqloxide==0.1.51
|
||||
# via apache-superset
|
||||
sse-starlette==3.0.2
|
||||
@@ -987,17 +941,15 @@ 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
|
||||
# anyio
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
# cattrs
|
||||
# exceptiongroup
|
||||
# limits
|
||||
# py-key-value-shared
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
# pyopenssl
|
||||
@@ -1030,9 +982,7 @@ urllib3==2.5.0
|
||||
# requests-cache
|
||||
# selenium
|
||||
uvicorn==0.37.0
|
||||
# via
|
||||
# fastmcp
|
||||
# mcp
|
||||
# via mcp
|
||||
vine==5.1.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -1054,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
|
||||
@@ -1091,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
|
||||
|
||||
@@ -19,23 +19,6 @@
|
||||
|
||||
set -e
|
||||
|
||||
# If not already running in Docker, run this script inside Docker
|
||||
if [ -z "$RUNNING_IN_DOCKER" ]; then
|
||||
# Extract "current" Python version from CI config (single source of truth)
|
||||
PYTHON_VERSION=$(grep -A 1 'if.*"current"' .github/actions/setup-backend/action.yml | grep 'PYTHON_VERSION=' | sed 's/.*PYTHON_VERSION=\([0-9.]*\).*/\1/')
|
||||
|
||||
echo "Running in Docker (Python ${PYTHON_VERSION} on Linux)..."
|
||||
|
||||
docker run --rm \
|
||||
-v "$(pwd)":/app \
|
||||
-w /app \
|
||||
-e RUNNING_IN_DOCKER=1 \
|
||||
python:${PYTHON_VERSION}-slim \
|
||||
bash -c "pip install uv && ./scripts/uv-pip-compile.sh $*"
|
||||
|
||||
exit $?
|
||||
fi
|
||||
|
||||
ADDITIONAL_ARGS="$@"
|
||||
|
||||
# Generate the requirements/base.txt file
|
||||
|
||||
@@ -49,7 +49,7 @@ The package is organized into logical modules, each providing specific functiona
|
||||
from flask import request, Response
|
||||
from flask_appbuilder.api import expose, permission_name, protect, safe
|
||||
from superset_core.api import models, query, rest_api
|
||||
from superset_core.api.rest_api import RestApi
|
||||
from superset_core.api.types.rest_api import RestApi
|
||||
|
||||
class DatasetReferencesAPI(RestApi):
|
||||
"""Example extension API demonstrating core functionality."""
|
||||
|
||||
@@ -42,11 +42,7 @@ classifiers = [
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
]
|
||||
dependencies = [
|
||||
"flask-appbuilder>=5.0.2,<6",
|
||||
"sqlalchemy>=1.4.0,<2.0",
|
||||
"sqlalchemy-utils>=0.38.0",
|
||||
"sqlglot>=27.15.2, <28",
|
||||
"typing-extensions>=4.0.0",
|
||||
"flask-appbuilder>=5.0.0,<6",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -14,7 +14,3 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Apache Superset Core - Public API with core functions of Superset
|
||||
"""
|
||||
|
||||
@@ -14,3 +14,11 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from .types.models import CoreModelsApi
|
||||
from .types.query import CoreQueryApi
|
||||
from .types.rest_api import CoreRestApi
|
||||
|
||||
models: CoreModelsApi
|
||||
rest_api: CoreRestApi
|
||||
query: CoreQueryApi
|
||||
|
||||
@@ -1,262 +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.
|
||||
|
||||
"""
|
||||
Data Access Object API for superset-core.
|
||||
|
||||
Provides dependency-injected DAO classes that will be replaced by
|
||||
host implementations during initialization.
|
||||
|
||||
Usage:
|
||||
from superset_core.api.daos import DatasetDAO, DatabaseDAO
|
||||
|
||||
# Use standard BaseDAO methods
|
||||
datasets = DatasetDAO.find_all()
|
||||
dataset = DatasetDAO.find_one_or_none(id=123)
|
||||
DatasetDAO.create(attributes={"name": "New Dataset"})
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, ClassVar, Generic, TypeVar
|
||||
|
||||
from flask_appbuilder.models.filters import BaseFilter
|
||||
from sqlalchemy.orm import Query as SQLAQuery
|
||||
|
||||
from superset_core.api.models import (
|
||||
Chart,
|
||||
CoreModel,
|
||||
Dashboard,
|
||||
Database,
|
||||
Dataset,
|
||||
KeyValue,
|
||||
Query,
|
||||
SavedQuery,
|
||||
Tag,
|
||||
User,
|
||||
)
|
||||
|
||||
# Type variable bound to our CoreModel
|
||||
T = TypeVar("T", bound=CoreModel)
|
||||
|
||||
|
||||
class BaseDAO(Generic[T], ABC):
|
||||
"""
|
||||
Abstract base class for DAOs.
|
||||
|
||||
This ABC defines the base that all DAOs should implement,
|
||||
providing consistent CRUD operations across Superset and extensions.
|
||||
"""
|
||||
|
||||
# Due to mypy limitations, we can't have `type[T]` here
|
||||
model_cls: ClassVar[type[Any] | None]
|
||||
base_filter: ClassVar[BaseFilter | None]
|
||||
id_column_name: ClassVar[str]
|
||||
uuid_column_name: ClassVar[str]
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def find_all(cls) -> list[T]:
|
||||
"""Get all entities that fit the base_filter."""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def find_one_or_none(cls, **filter_by: Any) -> T | None:
|
||||
"""Get the first entity that fits the base_filter."""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def create(
|
||||
cls,
|
||||
item: T | None = None,
|
||||
attributes: dict[str, Any] | None = None,
|
||||
) -> T:
|
||||
"""Create an object from the specified item and/or attributes."""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def update(
|
||||
cls,
|
||||
item: T | None = None,
|
||||
attributes: dict[str, Any] | None = None,
|
||||
) -> T:
|
||||
"""Update an object from the specified item and/or attributes."""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def delete(cls, items: list[T]) -> None:
|
||||
"""Delete the specified items."""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def query(cls, query: SQLAQuery) -> list[T]:
|
||||
"""Execute query with base_filter applied."""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def filter_by(cls, **filter_by: Any) -> list[T]:
|
||||
"""Get all entries that fit the base_filter."""
|
||||
...
|
||||
|
||||
|
||||
class DatasetDAO(BaseDAO[Dataset]):
|
||||
"""
|
||||
Abstract Dataset DAO interface.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
"""
|
||||
|
||||
# Class variables that will be set by host implementation
|
||||
model_cls = None
|
||||
base_filter = None
|
||||
id_column_name = "id"
|
||||
uuid_column_name = "uuid"
|
||||
|
||||
|
||||
class DatabaseDAO(BaseDAO[Database]):
|
||||
"""
|
||||
Abstract Database DAO interface.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
"""
|
||||
|
||||
# Class variables that will be set by host implementation
|
||||
model_cls = None
|
||||
base_filter = None
|
||||
id_column_name = "id"
|
||||
uuid_column_name = "uuid"
|
||||
|
||||
|
||||
class ChartDAO(BaseDAO[Chart]):
|
||||
"""
|
||||
Abstract Chart DAO interface.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
"""
|
||||
|
||||
# Class variables that will be set by host implementation
|
||||
model_cls = None
|
||||
base_filter = None
|
||||
id_column_name = "id"
|
||||
uuid_column_name = "uuid"
|
||||
|
||||
|
||||
class DashboardDAO(BaseDAO[Dashboard]):
|
||||
"""
|
||||
Abstract Dashboard DAO interface.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
"""
|
||||
|
||||
# Class variables that will be set by host implementation
|
||||
model_cls = None
|
||||
base_filter = None
|
||||
id_column_name = "id"
|
||||
uuid_column_name = "uuid"
|
||||
|
||||
|
||||
class UserDAO(BaseDAO[User]):
|
||||
"""
|
||||
Abstract User DAO interface.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
"""
|
||||
|
||||
# Class variables that will be set by host implementation
|
||||
model_cls = None
|
||||
base_filter = None
|
||||
id_column_name = "id"
|
||||
|
||||
|
||||
class QueryDAO(BaseDAO[Query]):
|
||||
"""
|
||||
Abstract Query DAO interface.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
"""
|
||||
|
||||
# Class variables that will be set by host implementation
|
||||
model_cls = None
|
||||
base_filter = None
|
||||
id_column_name = "id"
|
||||
|
||||
|
||||
class SavedQueryDAO(BaseDAO[SavedQuery]):
|
||||
"""
|
||||
Abstract SavedQuery DAO interface.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
"""
|
||||
|
||||
# Class variables that will be set by host implementation
|
||||
model_cls = None
|
||||
base_filter = None
|
||||
id_column_name = "id"
|
||||
|
||||
|
||||
class TagDAO(BaseDAO[Tag]):
|
||||
"""
|
||||
Abstract Tag DAO interface.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
"""
|
||||
|
||||
# Class variables that will be set by host implementation
|
||||
model_cls = None
|
||||
base_filter = None
|
||||
id_column_name = "id"
|
||||
|
||||
|
||||
class KeyValueDAO(BaseDAO[KeyValue]):
|
||||
"""
|
||||
Abstract KeyValue DAO interface.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
"""
|
||||
|
||||
# Class variables that will be set by host implementation
|
||||
model_cls = None
|
||||
base_filter = None
|
||||
id_column_name = "id"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BaseDAO",
|
||||
"DatasetDAO",
|
||||
"DatabaseDAO",
|
||||
"ChartDAO",
|
||||
"DashboardDAO",
|
||||
"UserDAO",
|
||||
"QueryDAO",
|
||||
"SavedQueryDAO",
|
||||
"TagDAO",
|
||||
"KeyValueDAO",
|
||||
]
|
||||
@@ -1,295 +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.
|
||||
|
||||
"""
|
||||
Model API for superset-core.
|
||||
|
||||
Provides model classes that will be replaced by host implementations
|
||||
during initialization for extension developers to use.
|
||||
|
||||
Usage:
|
||||
from superset_core.api.models import Dataset, Database, get_session
|
||||
|
||||
# Use as regular model classes
|
||||
dataset = Dataset(name="My Dataset")
|
||||
db = Database(database_name="My DB")
|
||||
session = get_session()
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from flask_appbuilder import Model
|
||||
from sqlalchemy.orm import scoped_session
|
||||
|
||||
|
||||
class CoreModel(Model):
|
||||
"""
|
||||
Abstract base class that extends Flask-AppBuilder's Model.
|
||||
|
||||
This base class provides the interface contract for all Superset models.
|
||||
The host package provides concrete implementations.
|
||||
"""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
|
||||
class Database(CoreModel):
|
||||
"""
|
||||
Abstract class for Database models.
|
||||
|
||||
This abstract class defines the contract that database models should implement,
|
||||
providing consistent database connectivity and metadata operations.
|
||||
"""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
id: int
|
||||
verbose_name: str
|
||||
database_name: str | None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def backend(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def data(self) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Dataset(CoreModel):
|
||||
"""
|
||||
Abstract class for Dataset models.
|
||||
|
||||
This abstract class defines the contract that dataset models should implement,
|
||||
providing consistent data source operations and metadata.
|
||||
|
||||
It provides the public API for Datasets implemented by the host application.
|
||||
"""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
# Type hints for expected attributes (no actual field definitions)
|
||||
id: int
|
||||
uuid: UUID | None
|
||||
table_name: str | None
|
||||
main_dttm_col: str | None
|
||||
database_id: int | None
|
||||
schema: str | None
|
||||
catalog: str | None
|
||||
sql: str | None # For virtual datasets
|
||||
description: str | None
|
||||
default_endpoint: str | None
|
||||
is_featured: bool
|
||||
filter_select_enabled: bool
|
||||
offset: int
|
||||
cache_timeout: int
|
||||
params: str
|
||||
perm: str | None
|
||||
schema_perm: str | None
|
||||
catalog_perm: str | None
|
||||
is_managed_externally: bool
|
||||
external_url: str | None
|
||||
fetch_values_predicate: str | None
|
||||
is_sqllab_view: bool
|
||||
template_params: str | None
|
||||
extra: str | None # JSON string
|
||||
normalize_columns: bool
|
||||
always_filter_main_dttm: bool
|
||||
folders: str | None # JSON string
|
||||
|
||||
|
||||
class Chart(CoreModel):
|
||||
"""
|
||||
Abstract Chart/Slice model interface.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with concrete implementation providing actual functionality.
|
||||
"""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
# Type hints for expected attributes (no actual field definitions)
|
||||
id: int
|
||||
uuid: UUID | None
|
||||
slice_name: str | None
|
||||
datasource_id: int | None
|
||||
datasource_type: str | None
|
||||
datasource_name: str | None
|
||||
viz_type: str | None
|
||||
params: str | None
|
||||
query_context: str | None
|
||||
description: str | None
|
||||
cache_timeout: int
|
||||
certified_by: str | None
|
||||
certification_details: str | None
|
||||
is_managed_externally: bool
|
||||
external_url: str | None
|
||||
|
||||
|
||||
class Dashboard(CoreModel):
|
||||
"""
|
||||
Abstract Dashboard model interface.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with concrete implementation providing actual functionality.
|
||||
"""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
# Type hints for expected attributes (no actual field definitions)
|
||||
id: int
|
||||
uuid: UUID | None
|
||||
dashboard_title: str | None
|
||||
position_json: str | None
|
||||
description: str | None
|
||||
css: str | None
|
||||
json_metadata: str | None
|
||||
slug: str | None
|
||||
published: bool
|
||||
certified_by: str | None
|
||||
certification_details: str | None
|
||||
is_managed_externally: bool
|
||||
external_url: str | None
|
||||
|
||||
|
||||
class User(CoreModel):
|
||||
"""
|
||||
Abstract User model interface.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with concrete implementation providing actual functionality.
|
||||
"""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
# Type hints for expected attributes (no actual field definitions)
|
||||
id: int
|
||||
username: str | None
|
||||
email: str | None
|
||||
first_name: str | None
|
||||
last_name: str | None
|
||||
active: bool
|
||||
|
||||
|
||||
class Query(CoreModel):
|
||||
"""
|
||||
Abstract Query model interface.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with concrete implementation providing actual functionality.
|
||||
"""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
# Type hints for expected attributes (no actual field definitions)
|
||||
id: int
|
||||
client_id: str | None
|
||||
database_id: int | None
|
||||
sql: str | None
|
||||
status: str | None
|
||||
user_id: int | None
|
||||
progress: int
|
||||
error_message: str | None
|
||||
|
||||
|
||||
class SavedQuery(CoreModel):
|
||||
"""
|
||||
Abstract SavedQuery model interface.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with concrete implementation providing actual functionality.
|
||||
"""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
# Type hints for expected attributes (no actual field definitions)
|
||||
id: int
|
||||
uuid: UUID | None
|
||||
label: str | None
|
||||
sql: str | None
|
||||
database_id: int | None
|
||||
description: str | None
|
||||
user_id: int | None
|
||||
|
||||
|
||||
class Tag(CoreModel):
|
||||
"""
|
||||
Abstract Tag model interface.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with concrete implementation providing actual functionality.
|
||||
"""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
# Type hints for expected attributes (no actual field definitions)
|
||||
id: int
|
||||
name: str | None
|
||||
type: str | None
|
||||
|
||||
|
||||
class KeyValue(CoreModel):
|
||||
"""
|
||||
Abstract KeyValue model interface.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with concrete implementation providing actual functionality.
|
||||
"""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
id: int
|
||||
uuid: UUID | None
|
||||
resource: str | None
|
||||
value: str | None # Encoded value
|
||||
expires_on: datetime | None
|
||||
created_by_fk: int | None
|
||||
changed_by_fk: int | None
|
||||
|
||||
|
||||
def get_session() -> scoped_session:
|
||||
"""
|
||||
Retrieve the SQLAlchemy session to directly interface with the
|
||||
Superset models.
|
||||
|
||||
Host implementations will replace this function during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
|
||||
:returns: The SQLAlchemy scoped session instance.
|
||||
"""
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Dataset",
|
||||
"Database",
|
||||
"Chart",
|
||||
"Dashboard",
|
||||
"User",
|
||||
"Query",
|
||||
"SavedQuery",
|
||||
"Tag",
|
||||
"KeyValue",
|
||||
"CoreModel",
|
||||
"get_session",
|
||||
]
|
||||
@@ -1,51 +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.
|
||||
|
||||
"""
|
||||
Query API for superset-core.
|
||||
|
||||
Provides dependency-injected query utility functions that will be replaced by
|
||||
host implementations during initialization.
|
||||
|
||||
Usage:
|
||||
from superset_core.api.query import get_sqlglot_dialect
|
||||
|
||||
dialect = get_sqlglot_dialect(database)
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlglot import Dialects
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset_core.api.models import Database
|
||||
|
||||
|
||||
def get_sqlglot_dialect(database: "Database") -> Dialects:
|
||||
"""
|
||||
Get the SQLGlot dialect for the specified database.
|
||||
|
||||
Host implementations will replace this function during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
|
||||
:param database: The database instance to get the dialect for.
|
||||
:returns: The SQLGlot dialect enum corresponding to the database.
|
||||
"""
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
|
||||
|
||||
__all__ = ["get_sqlglot_dialect"]
|
||||
@@ -1,72 +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.
|
||||
|
||||
"""
|
||||
REST API functions for superset-core.
|
||||
|
||||
Provides dependency-injected REST API utility functions that will be replaced by
|
||||
host implementations during initialization.
|
||||
|
||||
Usage:
|
||||
from superset_core.api.rest_api import add_api, add_extension_api
|
||||
|
||||
add_api(MyCustomAPI)
|
||||
add_extension_api(MyExtensionAPI)
|
||||
"""
|
||||
|
||||
from flask_appbuilder.api import BaseApi
|
||||
|
||||
|
||||
class RestApi(BaseApi):
|
||||
"""
|
||||
Base REST API class for Superset with browser login support.
|
||||
|
||||
This class extends Flask-AppBuilder's BaseApi and enables browser-based
|
||||
authentication by default.
|
||||
"""
|
||||
|
||||
allow_browser_login = True
|
||||
|
||||
|
||||
def add_api(api: type[RestApi]) -> None:
|
||||
"""
|
||||
Add a REST API to the Superset API.
|
||||
|
||||
Host implementations will replace this function during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
|
||||
:param api: A REST API instance.
|
||||
:returns: None.
|
||||
"""
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
|
||||
|
||||
def add_extension_api(api: type[RestApi]) -> None:
|
||||
"""
|
||||
Add an extension REST API to the Superset API.
|
||||
|
||||
Host implementations will replace this function during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
|
||||
:param api: An extension REST API instance. These are placed under
|
||||
the /extensions resource.
|
||||
:returns: None.
|
||||
"""
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
|
||||
|
||||
__all__ = ["RestApi", "add_api", "add_extension_api"]
|
||||
90
superset-core/src/superset_core/api/types/models.py
Normal file
90
superset-core/src/superset_core/api/types/models.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# 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 abc import ABC, abstractmethod
|
||||
from typing import Any, Type
|
||||
|
||||
from flask_sqlalchemy import BaseQuery
|
||||
from sqlalchemy.orm import scoped_session
|
||||
|
||||
|
||||
class CoreModelsApi(ABC):
|
||||
"""
|
||||
Abstract interface for accessing Superset data models.
|
||||
|
||||
This class defines the contract for retrieving SQLAlchemy sessions
|
||||
and model instances for datasets and databases within Superset.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def get_session() -> scoped_session:
|
||||
"""
|
||||
Retrieve the SQLAlchemy session to directly interface with the
|
||||
Superset models.
|
||||
|
||||
:returns: The SQLAlchemy scoped session instance.
|
||||
"""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def get_dataset_model() -> Type[Any]:
|
||||
"""
|
||||
Retrieve the Dataset (SqlaTable) SQLAlchemy model.
|
||||
|
||||
:returns: The Dataset SQLAlchemy model class.
|
||||
"""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def get_database_model() -> Type[Any]:
|
||||
"""
|
||||
Retrieve the Database SQLAlchemy model.
|
||||
|
||||
:returns: The Database SQLAlchemy model class.
|
||||
"""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def get_datasets(query: BaseQuery | None = None, **kwargs: Any) -> list[Any]:
|
||||
"""
|
||||
Retrieve Dataset (SqlaTable) entities.
|
||||
|
||||
:param query: A query with the Dataset model as the primary entity for complex
|
||||
queries.
|
||||
:param kwargs: Optional keyword arguments to filter datasets using SQLAlchemy's
|
||||
`filter_by()`.
|
||||
:returns: SqlaTable entities.
|
||||
"""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def get_databases(query: BaseQuery | None = None, **kwargs: Any) -> list[Any]:
|
||||
"""
|
||||
Retrieve Database entities.
|
||||
|
||||
:param query: A query with the Database model as the primary entity for complex
|
||||
queries.
|
||||
:param kwargs: Optional keyword arguments to filter databases using SQLAlchemy's
|
||||
`filter_by()`.
|
||||
:returns: Database entities.
|
||||
"""
|
||||
...
|
||||
@@ -14,3 +14,28 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from sqlglot import Dialects
|
||||
|
||||
|
||||
class CoreQueryApi(ABC):
|
||||
"""
|
||||
Abstract interface for query-related operations.
|
||||
|
||||
This class defines the contract for database query operations,
|
||||
including dialect handling and query processing.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def get_sqlglot_dialect(database: Any) -> Dialects:
|
||||
"""
|
||||
Get the SQLGlot dialect for the specified database.
|
||||
|
||||
:param database: The database instance to get the dialect for.
|
||||
:returns: The SQLGlot dialect enum corresponding to the database.
|
||||
"""
|
||||
...
|
||||
64
superset-core/src/superset_core/api/types/rest_api.py
Normal file
64
superset-core/src/superset_core/api/types/rest_api.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# 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 abc import ABC, abstractmethod
|
||||
from typing import Type
|
||||
|
||||
from flask_appbuilder.api import BaseApi
|
||||
|
||||
|
||||
class RestApi(BaseApi):
|
||||
"""
|
||||
Base REST API class for Superset with browser login support.
|
||||
|
||||
This class extends Flask-AppBuilder's BaseApi and enables browser-based
|
||||
authentication by default.
|
||||
"""
|
||||
|
||||
allow_browser_login = True
|
||||
|
||||
|
||||
class CoreRestApi(ABC):
|
||||
"""
|
||||
Abstract interface for managing REST APIs in Superset.
|
||||
|
||||
This class defines the contract for adding and managing REST APIs,
|
||||
including both core APIs and extension APIs.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def add_api(api: Type[RestApi]) -> None:
|
||||
"""
|
||||
Add a REST API to the Superset API.
|
||||
|
||||
:param api: A REST API instance.
|
||||
:returns: None.
|
||||
"""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def add_extension_api(api: Type[RestApi]) -> None:
|
||||
"""
|
||||
Add an extension REST API to the Superset API.
|
||||
|
||||
:param api: An extension REST API instance. These are placed under
|
||||
the /extensions resource.
|
||||
:returns: None.
|
||||
"""
|
||||
...
|
||||
12
superset-embedded-sdk/package-lock.json
generated
12
superset-embedded-sdk/package-lock.json
generated
@@ -6696,9 +6696,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"version": "3.14.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
|
||||
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
@@ -12972,9 +12972,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"version": "3.14.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
|
||||
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"argparse": "^1.0.7",
|
||||
|
||||
@@ -81,8 +81,6 @@ export type ObserveDataMaskCallbackFn = (
|
||||
nativeFiltersChanged: boolean;
|
||||
},
|
||||
) => void;
|
||||
export type ThemeMode = 'default' | 'dark' | 'system';
|
||||
|
||||
export type EmbeddedDashboard = {
|
||||
getScrollSize: () => Promise<Size>;
|
||||
unmount: () => void;
|
||||
@@ -94,7 +92,6 @@ export type EmbeddedDashboard = {
|
||||
getDataMask: () => Promise<Record<string, any>>;
|
||||
getChartStates: () => Promise<Record<string, any>>;
|
||||
setThemeConfig: (themeConfig: Record<string, any>) => void;
|
||||
setThemeMode: (mode: ThemeMode) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -268,18 +265,6 @@ export async function embedDashboard({
|
||||
}
|
||||
};
|
||||
|
||||
const setThemeMode = (mode: ThemeMode): void => {
|
||||
try {
|
||||
ourPort.emit('setThemeMode', { mode });
|
||||
log(`Theme mode set to: ${mode}`);
|
||||
} catch (error) {
|
||||
log(
|
||||
'Error sending theme mode. Ensure the iframe side implements the "setThemeMode" method.',
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getScrollSize,
|
||||
unmount,
|
||||
@@ -288,7 +273,6 @@ export async function embedDashboard({
|
||||
observeDataMask,
|
||||
getDataMask,
|
||||
getChartStates,
|
||||
setThemeConfig,
|
||||
setThemeMode,
|
||||
setThemeConfig
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
182
superset-frontend/package-lock.json
generated
182
superset-frontend/package-lock.json
generated
@@ -15,9 +15,6 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@apache-superset/core": "file:packages/superset-core",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
@@ -165,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",
|
||||
@@ -261,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",
|
||||
@@ -4294,16 +4289,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
|
||||
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
@@ -5334,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"
|
||||
}
|
||||
@@ -10750,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",
|
||||
@@ -19767,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",
|
||||
@@ -25905,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",
|
||||
@@ -29403,20 +29325,6 @@
|
||||
"url": "https://opencollective.com/geostyler"
|
||||
}
|
||||
},
|
||||
"node_modules/geostyler/node_modules/@dnd-kit/sortable": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
|
||||
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/geostyler/node_modules/geostyler-style": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/geostyler-style/-/geostyler-style-8.1.0.tgz",
|
||||
@@ -40732,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"
|
||||
}
|
||||
@@ -49574,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",
|
||||
@@ -54142,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",
|
||||
@@ -62037,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",
|
||||
@@ -62045,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": {
|
||||
@@ -65867,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": "*",
|
||||
@@ -66264,6 +66148,17 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-demo/node_modules/core-js": {
|
||||
"version": "3.39.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
|
||||
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-demo/node_modules/cosmiconfig": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||
@@ -66441,7 +66336,6 @@
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
@@ -66467,7 +66361,6 @@
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*"
|
||||
}
|
||||
@@ -66491,7 +66384,6 @@
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^17.0.2"
|
||||
@@ -66507,7 +66399,6 @@
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^17.0.2"
|
||||
@@ -66570,7 +66461,6 @@
|
||||
"supercluster": "^8.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"mapbox-gl": "*",
|
||||
@@ -66587,7 +66477,6 @@
|
||||
"reactable": "^1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^17.0.2"
|
||||
@@ -66602,7 +66491,6 @@
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^17.0.2"
|
||||
@@ -66618,7 +66506,6 @@
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@testing-library/jest-dom": "*",
|
||||
@@ -66637,7 +66524,6 @@
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
@@ -66656,7 +66542,6 @@
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^17.0.2"
|
||||
@@ -66703,7 +66588,6 @@
|
||||
"@types/urijs": "^1.19.25"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"mapbox-gl": "*",
|
||||
@@ -66835,7 +66719,6 @@
|
||||
"urijs": "^1.19.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^17.0.2"
|
||||
@@ -66894,7 +66777,6 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@apache-superset/core": "*",
|
||||
"@reduxjs/toolkit": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
@@ -66949,7 +66831,6 @@
|
||||
"jest": "^30.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"ace-builds": "^1.4.14",
|
||||
@@ -69403,7 +69284,6 @@
|
||||
"@types/d3-cloud": "^1.2.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@types/lodash": "*",
|
||||
|
||||
@@ -94,9 +94,6 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@apache-superset/core": "file:packages/superset-core",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
@@ -244,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",
|
||||
@@ -340,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",
|
||||
|
||||
@@ -342,31 +342,6 @@ const x_axis_time_format: SharedControlConfig<
|
||||
option.label.includes(search) || option.value.includes(search),
|
||||
};
|
||||
|
||||
const x_axis_number_format: SharedControlConfig<
|
||||
'SelectControl',
|
||||
SelectDefaultOption
|
||||
> = {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
label: t('X Axis Number Format'),
|
||||
renderTrigger: true,
|
||||
default: DEFAULT_NUMBER_FORMAT,
|
||||
choices: D3_FORMAT_OPTIONS,
|
||||
description: D3_FORMAT_DOCS,
|
||||
tokenSeparators: ['\n', '\t', ';'],
|
||||
filterOption: ({ data: option }, search) =>
|
||||
option.label.includes(search) || option.value.includes(search),
|
||||
mapStateToProps: state => {
|
||||
const isPercentage =
|
||||
state.controls?.comparison_type?.value === ComparisonType.Percentage;
|
||||
return {
|
||||
choices: isPercentage
|
||||
? D3_FORMAT_OPTIONS.filter(option => option[0].includes('%'))
|
||||
: D3_FORMAT_OPTIONS,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const color_scheme: SharedControlConfig<'ColorSchemeControl'> = {
|
||||
type: 'ColorSchemeControl',
|
||||
label: t('Color Scheme'),
|
||||
@@ -481,7 +456,6 @@ const sharedControls: Record<string, SharedControlConfig<any>> = {
|
||||
size: dndSizeControl,
|
||||
y_axis_format,
|
||||
x_axis_time_format,
|
||||
x_axis_number_format,
|
||||
adhoc_filters: dndAdhocFilterControl,
|
||||
color_scheme,
|
||||
time_shift_color,
|
||||
|
||||
@@ -38,24 +38,20 @@ export const getOpacity = (
|
||||
minOpacity = MIN_OPACITY_BOUNDED,
|
||||
maxOpacity = MAX_OPACITY,
|
||||
) => {
|
||||
if (extremeValue === cutoffPoint || typeof value !== 'number') {
|
||||
if (
|
||||
extremeValue === cutoffPoint ||
|
||||
typeof cutoffPoint !== 'number' ||
|
||||
typeof extremeValue !== 'number' ||
|
||||
typeof value !== 'number'
|
||||
) {
|
||||
return maxOpacity;
|
||||
}
|
||||
const numCutoffPoint =
|
||||
typeof cutoffPoint === 'string' ? parseFloat(cutoffPoint) : cutoffPoint;
|
||||
const numExtremeValue =
|
||||
typeof extremeValue === 'string' ? parseFloat(extremeValue) : extremeValue;
|
||||
|
||||
if (isNaN(numCutoffPoint) || isNaN(numExtremeValue)) {
|
||||
return maxOpacity;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
maxOpacity,
|
||||
round(
|
||||
Math.abs(
|
||||
((maxOpacity - minOpacity) / (numExtremeValue - numCutoffPoint)) *
|
||||
(value - numCutoffPoint),
|
||||
((maxOpacity - minOpacity) / (extremeValue - cutoffPoint)) *
|
||||
(value - cutoffPoint),
|
||||
) + minOpacity,
|
||||
2,
|
||||
),
|
||||
|
||||
@@ -35,500 +35,487 @@ const countValues = mockData.map(row => row.count);
|
||||
const strData = [{ name: 'Brian' }, { name: 'Carlos' }, { name: 'Diana' }];
|
||||
const strValues = strData.map(row => row.name);
|
||||
|
||||
test('round', () => {
|
||||
expect(round(1)).toEqual(1);
|
||||
expect(round(1, 2)).toEqual(1);
|
||||
expect(round(0.6)).toEqual(1);
|
||||
expect(round(0.6, 1)).toEqual(0.6);
|
||||
expect(round(0.64999, 2)).toEqual(0.65);
|
||||
describe('round', () => {
|
||||
it('round', () => {
|
||||
expect(round(1)).toEqual(1);
|
||||
expect(round(1, 2)).toEqual(1);
|
||||
expect(round(0.6)).toEqual(1);
|
||||
expect(round(0.6, 1)).toEqual(0.6);
|
||||
expect(round(0.64999, 2)).toEqual(0.65);
|
||||
});
|
||||
});
|
||||
|
||||
test('getOpacity', () => {
|
||||
expect(getOpacity(100, 100, 100)).toEqual(1);
|
||||
expect(getOpacity(75, 50, 100)).toEqual(0.53);
|
||||
expect(getOpacity(75, 100, 50)).toEqual(0.53);
|
||||
expect(getOpacity(100, 100, 50)).toEqual(0.05);
|
||||
expect(getOpacity(100, 100, 100, 0, 0.8)).toEqual(0.8);
|
||||
expect(getOpacity(100, 100, 50, 0, 1)).toEqual(0);
|
||||
expect(getOpacity(999, 100, 50, 0, 1)).toEqual(1);
|
||||
expect(getOpacity(100, 100, 50, 0.99, 1)).toEqual(0.99);
|
||||
expect(getOpacity(99, 100, 50, 0, 1)).toEqual(0.02);
|
||||
|
||||
expect(getOpacity('100', 100, 100)).toEqual(1);
|
||||
expect(getOpacity('75', 50, 100)).toEqual(1);
|
||||
expect(getOpacity('50', '100', '100')).toEqual(1);
|
||||
expect(getOpacity('50', '75', '100')).toEqual(1);
|
||||
expect(getOpacity('50', NaN, '100')).toEqual(1);
|
||||
expect(getOpacity('50', '75', NaN)).toEqual(1);
|
||||
expect(getOpacity('50', NaN, 100)).toEqual(1);
|
||||
expect(getOpacity('50', '75', NaN)).toEqual(1);
|
||||
expect(getOpacity('50', NaN, NaN)).toEqual(1);
|
||||
|
||||
expect(getOpacity(75, 50, 100)).toEqual(0.53);
|
||||
expect(getOpacity(100, 50, 100)).toEqual(1);
|
||||
expect(getOpacity(75, '50', 100)).toEqual(0.53);
|
||||
expect(getOpacity(75, 50, '100')).toEqual(0.53);
|
||||
expect(getOpacity(75, '50', '100')).toEqual(0.53);
|
||||
expect(getOpacity(50, NaN, NaN)).toEqual(1);
|
||||
expect(getOpacity(50, NaN, 100)).toEqual(1);
|
||||
expect(getOpacity(50, NaN, '100')).toEqual(1);
|
||||
expect(getOpacity(50, '75', NaN)).toEqual(1);
|
||||
expect(getOpacity(50, 75, NaN)).toEqual(1);
|
||||
describe('getOpacity', () => {
|
||||
it('getOpacity', () => {
|
||||
expect(getOpacity(100, 100, 100)).toEqual(1);
|
||||
expect(getOpacity(75, 50, 100)).toEqual(0.53);
|
||||
expect(getOpacity(75, 100, 50)).toEqual(0.53);
|
||||
expect(getOpacity(100, 100, 50)).toEqual(0.05);
|
||||
expect(getOpacity(100, 100, 100, 0, 0.8)).toEqual(0.8);
|
||||
expect(getOpacity(100, 100, 50, 0, 1)).toEqual(0);
|
||||
expect(getOpacity(999, 100, 50, 0, 1)).toEqual(1);
|
||||
expect(getOpacity(100, 100, 50, 0.99, 1)).toEqual(0.99);
|
||||
expect(getOpacity(99, 100, 50, 0, 1)).toEqual(0.02);
|
||||
});
|
||||
});
|
||||
|
||||
test('getColorFunction GREATER_THAN', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
describe('getColorFunction()', () => {
|
||||
it('getColorFunction GREATER_THAN', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction LESS_THAN', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.LessThan,
|
||||
targetValue: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
expect(colorFunction(50)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction GREATER_OR_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterOrEqual,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toEqual('#FF00000D');
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction LESS_OR_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.LessOrEqual,
|
||||
targetValue: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(100)).toEqual('#FF00000D');
|
||||
expect(colorFunction(150)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Equal,
|
||||
targetValue: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction NOT_EQUAL', () => {
|
||||
let colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.NotEqual,
|
||||
targetValue: 60,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(60)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(50)).toEqual('#FF00004A');
|
||||
|
||||
colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.NotEqual,
|
||||
targetValue: 90,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(90)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF00004A');
|
||||
expect(colorFunction(50)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction BETWEEN', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Between,
|
||||
targetValueLeft: 75,
|
||||
targetValueRight: 125,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF000087');
|
||||
});
|
||||
|
||||
it('getColorFunction BETWEEN_OR_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BetweenOrEqual,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toEqual('#FF00000D');
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(150)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction BETWEEN_OR_EQUAL without opacity', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BetweenOrEqual,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
false,
|
||||
);
|
||||
expect(colorFunction(25)).toBeUndefined();
|
||||
expect(colorFunction(50)).toEqual('#FF0000');
|
||||
expect(colorFunction(75)).toEqual('#FF0000');
|
||||
expect(colorFunction(100)).toEqual('#FF0000');
|
||||
expect(colorFunction(125)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction BETWEEN_OR_LEFT_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BetweenOrLeftEqual,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toEqual('#FF00000D');
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction BETWEEN_OR_RIGHT_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BetweenOrRightEqual,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction GREATER_THAN with target value undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: undefined,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction BETWEEN with target value left undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Between,
|
||||
targetValueLeft: undefined,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction BETWEEN with target value right undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Between,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: undefined,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction unsupported operator', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
// @ts-ignore
|
||||
operator: 'unsupported operator',
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction with operator None', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.None,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(20)).toEqual(undefined);
|
||||
expect(colorFunction(50)).toEqual('#FF000000');
|
||||
expect(colorFunction(75)).toEqual('#FF000080');
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(120)).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('getColorFunction with operator undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: undefined,
|
||||
targetValue: 150,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction with colorScheme undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 150,
|
||||
colorScheme: undefined,
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction BeginsWith', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BeginsWith,
|
||||
targetValue: 'C',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Brian')).toBeUndefined();
|
||||
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction EndsWith', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.EndsWith,
|
||||
targetValue: 'n',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Carlos')).toBeUndefined();
|
||||
expect(colorFunction('Brian')).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction Containing', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Containing,
|
||||
targetValue: 'o',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Diana')).toBeUndefined();
|
||||
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction NotContaining', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.NotContaining,
|
||||
targetValue: 'i',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Diana')).toBeUndefined();
|
||||
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction Equal', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Equal,
|
||||
targetValue: 'Diana',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Carlos')).toBeUndefined();
|
||||
expect(colorFunction('Diana')).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction None', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.None,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Diana')).toEqual('#FF0000FF');
|
||||
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
|
||||
expect(colorFunction('Brian')).toEqual('#FF0000FF');
|
||||
});
|
||||
});
|
||||
|
||||
test('getColorFunction LESS_THAN', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.LessThan,
|
||||
targetValue: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
expect(colorFunction(50)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
test('getColorFunction GREATER_OR_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterOrEqual,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toEqual('#FF00000D');
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(0)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction LESS_OR_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.LessOrEqual,
|
||||
targetValue: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(100)).toEqual('#FF00000D');
|
||||
expect(colorFunction(150)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Equal,
|
||||
targetValue: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
test('getColorFunction NOT_EQUAL', () => {
|
||||
let colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.NotEqual,
|
||||
targetValue: 60,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(60)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(50)).toEqual('#FF00004A');
|
||||
|
||||
colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.NotEqual,
|
||||
targetValue: 90,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(90)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF00004A');
|
||||
expect(colorFunction(50)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
test('getColorFunction BETWEEN', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Between,
|
||||
targetValueLeft: 75,
|
||||
targetValueRight: 125,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF000087');
|
||||
});
|
||||
|
||||
test('getColorFunction BETWEEN_OR_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BetweenOrEqual,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toEqual('#FF00000D');
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(150)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction BETWEEN_OR_EQUAL without opacity', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BetweenOrEqual,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
false,
|
||||
);
|
||||
expect(colorFunction(25)).toBeUndefined();
|
||||
expect(colorFunction(50)).toEqual('#FF0000');
|
||||
expect(colorFunction(75)).toEqual('#FF0000');
|
||||
expect(colorFunction(100)).toEqual('#FF0000');
|
||||
expect(colorFunction(125)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction BETWEEN_OR_LEFT_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BetweenOrLeftEqual,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toEqual('#FF00000D');
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction BETWEEN_OR_RIGHT_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BetweenOrRightEqual,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
test('getColorFunction GREATER_THAN with target value undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: undefined,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction BETWEEN with target value left undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Between,
|
||||
targetValueLeft: undefined,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction BETWEEN with target value right undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Between,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: undefined,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction unsupported operator', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
// @ts-ignore
|
||||
operator: 'unsupported operator',
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction with operator None', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.None,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(20)).toEqual(undefined);
|
||||
expect(colorFunction(50)).toEqual('#FF000000');
|
||||
expect(colorFunction(75)).toEqual('#FF000080');
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(120)).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('getColorFunction with operator undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: undefined,
|
||||
targetValue: 150,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction with colorScheme undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 150,
|
||||
colorScheme: undefined,
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction BeginsWith', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BeginsWith,
|
||||
targetValue: 'C',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Brian')).toBeUndefined();
|
||||
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
test('getColorFunction EndsWith', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.EndsWith,
|
||||
targetValue: 'n',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Carlos')).toBeUndefined();
|
||||
expect(colorFunction('Brian')).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
test('getColorFunction Containing', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Containing,
|
||||
targetValue: 'o',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Diana')).toBeUndefined();
|
||||
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
test('getColorFunction NotContaining', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.NotContaining,
|
||||
targetValue: 'i',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Diana')).toBeUndefined();
|
||||
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
test('getColorFunction Equal', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Equal,
|
||||
targetValue: 'Diana',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Carlos')).toBeUndefined();
|
||||
expect(colorFunction('Diana')).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
test('getColorFunction None', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.None,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Diana')).toEqual('#FF0000FF');
|
||||
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
|
||||
expect(colorFunction('Brian')).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
test('correct column config', () => {
|
||||
const columnConfig = [
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
{
|
||||
operator: Comparator.LessThan,
|
||||
targetValue: 300,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'sum',
|
||||
},
|
||||
{
|
||||
operator: Comparator.Between,
|
||||
targetValueLeft: 75,
|
||||
targetValueRight: 125,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 150,
|
||||
colorScheme: '#FF0000',
|
||||
column: undefined,
|
||||
},
|
||||
];
|
||||
const colorFormatters = getColorFormatters(columnConfig, mockData);
|
||||
expect(colorFormatters.length).toEqual(3);
|
||||
|
||||
expect(colorFormatters[0].column).toEqual('count');
|
||||
expect(colorFormatters[0].getColorFromValue(100)).toEqual('#FF0000FF');
|
||||
|
||||
expect(colorFormatters[1].column).toEqual('sum');
|
||||
expect(colorFormatters[1].getColorFromValue(200)).toEqual('#FF0000FF');
|
||||
expect(colorFormatters[1].getColorFromValue(400)).toBeUndefined();
|
||||
|
||||
expect(colorFormatters[2].column).toEqual('count');
|
||||
expect(colorFormatters[2].getColorFromValue(100)).toEqual('#FF000087');
|
||||
});
|
||||
|
||||
test('undefined column config', () => {
|
||||
const colorFormatters = getColorFormatters(undefined, mockData);
|
||||
expect(colorFormatters.length).toEqual(0);
|
||||
});
|
||||
|
||||
test('correct column string config', () => {
|
||||
const columnConfigString = [
|
||||
{
|
||||
operator: Comparator.BeginsWith,
|
||||
targetValue: 'D',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
{
|
||||
operator: Comparator.EndsWith,
|
||||
targetValue: 'n',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
{
|
||||
operator: Comparator.Containing,
|
||||
targetValue: 'o',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
{
|
||||
operator: Comparator.NotContaining,
|
||||
targetValue: 'i',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
];
|
||||
const colorFormatters = getColorFormatters(columnConfigString, strData);
|
||||
expect(colorFormatters.length).toEqual(4);
|
||||
|
||||
expect(colorFormatters[0].column).toEqual('name');
|
||||
expect(colorFormatters[0].getColorFromValue('Diana')).toEqual('#FF0000FF');
|
||||
|
||||
expect(colorFormatters[1].column).toEqual('name');
|
||||
expect(colorFormatters[1].getColorFromValue('Brian')).toEqual('#FF0000FF');
|
||||
|
||||
expect(colorFormatters[2].column).toEqual('name');
|
||||
expect(colorFormatters[2].getColorFromValue('Carlos')).toEqual('#FF0000FF');
|
||||
|
||||
expect(colorFormatters[3].column).toEqual('name');
|
||||
expect(colorFormatters[3].getColorFromValue('Carlos')).toEqual('#FF0000FF');
|
||||
describe('getColorFormatters()', () => {
|
||||
it('correct column config', () => {
|
||||
const columnConfig = [
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
{
|
||||
operator: Comparator.LessThan,
|
||||
targetValue: 300,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'sum',
|
||||
},
|
||||
{
|
||||
operator: Comparator.Between,
|
||||
targetValueLeft: 75,
|
||||
targetValueRight: 125,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 150,
|
||||
colorScheme: '#FF0000',
|
||||
column: undefined,
|
||||
},
|
||||
];
|
||||
const colorFormatters = getColorFormatters(columnConfig, mockData);
|
||||
expect(colorFormatters.length).toEqual(3);
|
||||
|
||||
expect(colorFormatters[0].column).toEqual('count');
|
||||
expect(colorFormatters[0].getColorFromValue(100)).toEqual('#FF0000FF');
|
||||
|
||||
expect(colorFormatters[1].column).toEqual('sum');
|
||||
expect(colorFormatters[1].getColorFromValue(200)).toEqual('#FF0000FF');
|
||||
expect(colorFormatters[1].getColorFromValue(400)).toBeUndefined();
|
||||
|
||||
expect(colorFormatters[2].column).toEqual('count');
|
||||
expect(colorFormatters[2].getColorFromValue(100)).toEqual('#FF000087');
|
||||
});
|
||||
|
||||
it('undefined column config', () => {
|
||||
const colorFormatters = getColorFormatters(undefined, mockData);
|
||||
expect(colorFormatters.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('correct column string config', () => {
|
||||
const columnConfigString = [
|
||||
{
|
||||
operator: Comparator.BeginsWith,
|
||||
targetValue: 'D',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
{
|
||||
operator: Comparator.EndsWith,
|
||||
targetValue: 'n',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
{
|
||||
operator: Comparator.Containing,
|
||||
targetValue: 'o',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
{
|
||||
operator: Comparator.NotContaining,
|
||||
targetValue: 'i',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
];
|
||||
const colorFormatters = getColorFormatters(columnConfigString, strData);
|
||||
expect(colorFormatters.length).toEqual(4);
|
||||
|
||||
expect(colorFormatters[0].column).toEqual('name');
|
||||
expect(colorFormatters[0].getColorFromValue('Diana')).toEqual('#FF0000FF');
|
||||
|
||||
expect(colorFormatters[1].column).toEqual('name');
|
||||
expect(colorFormatters[1].getColorFromValue('Brian')).toEqual('#FF0000FF');
|
||||
|
||||
expect(colorFormatters[2].column).toEqual('name');
|
||||
expect(colorFormatters[2].getColorFromValue('Carlos')).toEqual('#FF0000FF');
|
||||
|
||||
expect(colorFormatters[3].column).toEqual('name');
|
||||
expect(colorFormatters[3].getColorFromValue('Carlos')).toEqual('#FF0000FF');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,9 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createRef } from 'react';
|
||||
import { render, screen, waitFor } from '@superset-ui/core/spec';
|
||||
import type AceEditor from 'react-ace';
|
||||
import {
|
||||
AsyncAceEditor,
|
||||
SQLEditor,
|
||||
@@ -101,253 +99,3 @@ test('renders a custom placeholder', () => {
|
||||
|
||||
expect(screen.getByRole('paragraph')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('registers afterExec event listener for command handling', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editorInstance = ref.current?.editor;
|
||||
expect(editorInstance).toBeDefined();
|
||||
|
||||
if (!editorInstance) return;
|
||||
|
||||
// Verify the commands object has the 'on' method (confirms event listener capability)
|
||||
expect(editorInstance.commands).toHaveProperty('on');
|
||||
expect(typeof editorInstance.commands.on).toBe('function');
|
||||
});
|
||||
|
||||
test('moves autocomplete popup to parent container when triggered', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editorInstance = ref.current?.editor;
|
||||
expect(editorInstance).toBeDefined();
|
||||
|
||||
if (!editorInstance) return;
|
||||
|
||||
// Create a mock autocomplete popup in the editor container
|
||||
const mockAutocompletePopup = document.createElement('div');
|
||||
mockAutocompletePopup.className = 'ace_autocomplete';
|
||||
editorInstance.container?.appendChild(mockAutocompletePopup);
|
||||
|
||||
const parentContainer =
|
||||
editorInstance.container?.closest('#ace-editor') ??
|
||||
editorInstance.container?.parentElement;
|
||||
|
||||
// Manually trigger the afterExec event with insertstring command using _emit
|
||||
type CommandManagerWithEmit = typeof editorInstance.commands & {
|
||||
_emit: (event: string, data: unknown) => void;
|
||||
};
|
||||
(editorInstance.commands as CommandManagerWithEmit)._emit('afterExec', {
|
||||
command: { name: 'insertstring' },
|
||||
args: ['SELECT'],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Check that the popup has the data attribute set
|
||||
expect(mockAutocompletePopup.dataset.aceAutocomplete).toBe('true');
|
||||
// Check that the popup is in the parent container
|
||||
expect(parentContainer?.contains(mockAutocompletePopup)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('moves autocomplete popup on startAutocomplete command event', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editorInstance = ref.current?.editor;
|
||||
expect(editorInstance).toBeDefined();
|
||||
|
||||
if (!editorInstance) return;
|
||||
|
||||
// Create a mock autocomplete popup
|
||||
const mockAutocompletePopup = document.createElement('div');
|
||||
mockAutocompletePopup.className = 'ace_autocomplete';
|
||||
editorInstance.container?.appendChild(mockAutocompletePopup);
|
||||
|
||||
const parentContainer =
|
||||
editorInstance.container?.closest('#ace-editor') ??
|
||||
editorInstance.container?.parentElement;
|
||||
|
||||
// Manually trigger the afterExec event with startAutocomplete command
|
||||
type CommandManagerWithEmit = typeof editorInstance.commands & {
|
||||
_emit: (event: string, data: unknown) => void;
|
||||
};
|
||||
(editorInstance.commands as CommandManagerWithEmit)._emit('afterExec', {
|
||||
command: { name: 'startAutocomplete' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Check that the popup has the data attribute set
|
||||
expect(mockAutocompletePopup.dataset.aceAutocomplete).toBe('true');
|
||||
// Check that the popup is in the parent container
|
||||
expect(parentContainer?.contains(mockAutocompletePopup)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not move autocomplete popup on unrelated commands', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editorInstance = ref.current?.editor;
|
||||
expect(editorInstance).toBeDefined();
|
||||
|
||||
if (!editorInstance) return;
|
||||
|
||||
// Create a mock autocomplete popup in the body
|
||||
const mockAutocompletePopup = document.createElement('div');
|
||||
mockAutocompletePopup.className = 'ace_autocomplete';
|
||||
document.body.appendChild(mockAutocompletePopup);
|
||||
|
||||
const originalParent = mockAutocompletePopup.parentElement;
|
||||
|
||||
// Simulate an unrelated command (e.g., 'selectall')
|
||||
editorInstance.commands.exec('selectall', editorInstance, {});
|
||||
|
||||
// Wait a bit to ensure no movement happens
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
|
||||
// The popup should remain in its original location
|
||||
expect(mockAutocompletePopup.parentElement).toBe(originalParent);
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(mockAutocompletePopup);
|
||||
});
|
||||
|
||||
test('revalidates cached autocomplete popup when detached from DOM', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editorInstance = ref.current?.editor;
|
||||
expect(editorInstance).toBeDefined();
|
||||
|
||||
if (!editorInstance) return;
|
||||
|
||||
// Create first autocomplete popup
|
||||
const firstPopup = document.createElement('div');
|
||||
firstPopup.className = 'ace_autocomplete';
|
||||
editorInstance.container?.appendChild(firstPopup);
|
||||
|
||||
// Trigger command to cache the first popup
|
||||
editorInstance.commands.exec('insertstring', editorInstance, 'SELECT');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(firstPopup.dataset.aceAutocomplete).toBe('true');
|
||||
});
|
||||
|
||||
// Remove the first popup from DOM (simulating ACE editor replacing it)
|
||||
firstPopup.remove();
|
||||
|
||||
// Create a new autocomplete popup
|
||||
const secondPopup = document.createElement('div');
|
||||
secondPopup.className = 'ace_autocomplete';
|
||||
editorInstance.container?.appendChild(secondPopup);
|
||||
|
||||
// Trigger command again - should find and move the new popup
|
||||
editorInstance.commands.exec('insertstring', editorInstance, ' ');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(secondPopup.dataset.aceAutocomplete).toBe('true');
|
||||
const parentContainer =
|
||||
editorInstance.container?.closest('#ace-editor') ??
|
||||
editorInstance.container?.parentElement;
|
||||
expect(parentContainer?.contains(secondPopup)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('cleans up event listeners on unmount', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container, unmount } = render(
|
||||
<SQLEditor ref={ref as React.Ref<never>} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editorInstance = ref.current?.editor;
|
||||
expect(editorInstance).toBeDefined();
|
||||
|
||||
if (!editorInstance) return;
|
||||
|
||||
// Spy on the commands.off method
|
||||
const offSpy = jest.spyOn(editorInstance.commands, 'off');
|
||||
|
||||
// Unmount the component
|
||||
unmount();
|
||||
|
||||
// Verify that the event listener was removed
|
||||
expect(offSpy).toHaveBeenCalledWith('afterExec', expect.any(Function));
|
||||
|
||||
offSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('does not move autocomplete popup if target container is document.body', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editorInstance = ref.current?.editor;
|
||||
expect(editorInstance).toBeDefined();
|
||||
|
||||
if (!editorInstance) return;
|
||||
|
||||
// Create a mock autocomplete popup
|
||||
const mockAutocompletePopup = document.createElement('div');
|
||||
mockAutocompletePopup.className = 'ace_autocomplete';
|
||||
document.body.appendChild(mockAutocompletePopup);
|
||||
|
||||
// Mock the closest method to return null (simulating no #ace-editor parent)
|
||||
const originalClosest = editorInstance.container?.closest;
|
||||
if (editorInstance.container) {
|
||||
editorInstance.container.closest = jest.fn(() => null);
|
||||
}
|
||||
|
||||
// Mock parentElement to be document.body
|
||||
Object.defineProperty(editorInstance.container, 'parentElement', {
|
||||
value: document.body,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const initialParent = mockAutocompletePopup.parentElement;
|
||||
|
||||
// Trigger command
|
||||
editorInstance.commands.exec('insertstring', editorInstance, 'SELECT');
|
||||
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
|
||||
// The popup should NOT be moved because target container is document.body
|
||||
expect(mockAutocompletePopup.parentElement).toBe(initialParent);
|
||||
|
||||
// Cleanup
|
||||
if (editorInstance.container && originalClosest) {
|
||||
editorInstance.container.closest = originalClosest;
|
||||
}
|
||||
document.body.removeChild(mockAutocompletePopup);
|
||||
});
|
||||
|
||||
@@ -26,7 +26,6 @@ import type {
|
||||
} from 'brace';
|
||||
import type AceEditor from 'react-ace';
|
||||
import type { IAceEditorProps } from 'react-ace';
|
||||
import type { Ace } from 'ace-builds';
|
||||
|
||||
import {
|
||||
AsyncEsmComponent,
|
||||
@@ -208,68 +207,6 @@ export function AsyncAceEditor(
|
||||
}
|
||||
}, [keywords, setCompleters]);
|
||||
|
||||
// Move autocomplete popup to the nearest parent container with data-ace-container
|
||||
useEffect(() => {
|
||||
const editorInstance = (ref as React.RefObject<AceEditor>)?.current
|
||||
?.editor;
|
||||
if (!editorInstance) return;
|
||||
|
||||
const editorContainer = editorInstance.container;
|
||||
if (!editorContainer) return;
|
||||
|
||||
// Cache DOM elements to avoid repeated queries on every command execution
|
||||
let cachedAutocompletePopup: HTMLElement | null = null;
|
||||
let cachedTargetContainer: Element | null = null;
|
||||
|
||||
const moveAutocompleteToContainer = () => {
|
||||
// Revalidate cached popup if missing or detached from DOM
|
||||
if (
|
||||
!cachedAutocompletePopup ||
|
||||
!document.body.contains(cachedAutocompletePopup)
|
||||
) {
|
||||
cachedAutocompletePopup =
|
||||
editorContainer.querySelector<HTMLElement>(
|
||||
'.ace_autocomplete',
|
||||
) ?? document.querySelector<HTMLElement>('.ace_autocomplete');
|
||||
}
|
||||
|
||||
// Revalidate cached container if missing or detached
|
||||
if (
|
||||
!cachedTargetContainer ||
|
||||
!document.body.contains(cachedTargetContainer)
|
||||
) {
|
||||
cachedTargetContainer =
|
||||
editorContainer.closest('#ace-editor') ??
|
||||
editorContainer.parentElement;
|
||||
}
|
||||
|
||||
if (
|
||||
cachedAutocompletePopup &&
|
||||
cachedTargetContainer &&
|
||||
cachedTargetContainer !== document.body
|
||||
) {
|
||||
cachedTargetContainer.appendChild(cachedAutocompletePopup);
|
||||
cachedAutocompletePopup.dataset.aceAutocomplete = 'true';
|
||||
}
|
||||
};
|
||||
|
||||
const handleAfterExec = (e: Ace.Operation) => {
|
||||
const name: string | undefined = e?.command?.name;
|
||||
if (name === 'insertstring' || name === 'startAutocomplete') {
|
||||
moveAutocompleteToContainer();
|
||||
}
|
||||
};
|
||||
|
||||
const { commands } = editorInstance;
|
||||
commands.on('afterExec', handleAfterExec);
|
||||
|
||||
return () => {
|
||||
commands.off('afterExec', handleAfterExec);
|
||||
cachedAutocompletePopup = null;
|
||||
cachedTargetContainer = null;
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Global
|
||||
@@ -351,24 +288,14 @@ export function AsyncAceEditor(
|
||||
border: 1px solid ${token.colorBorderSecondary};
|
||||
box-shadow: ${token.boxShadow};
|
||||
border-radius: ${token.borderRadius}px;
|
||||
padding: ${token.paddingXS}px ${token.paddingXS}px;
|
||||
}
|
||||
|
||||
.ace_tooltip.ace_doc-tooltip {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
&&& .tooltip-detail {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
gap: ${token.paddingXXS}px;
|
||||
align-items: center;
|
||||
& .tooltip-detail {
|
||||
background-color: ${token.colorBgContainer};
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
min-width: ${token.sizeXXL * 5}px;
|
||||
max-width: ${token.sizeXXL * 10}px;
|
||||
font-size: ${token.fontSize}px;
|
||||
|
||||
& .tooltip-detail-head {
|
||||
background-color: ${token.colorBgElevated};
|
||||
@@ -391,9 +318,7 @@ export function AsyncAceEditor(
|
||||
|
||||
& .tooltip-detail-head,
|
||||
& .tooltip-detail-body {
|
||||
background-color: ${token.colorBgLayout};
|
||||
padding: 0px ${token.paddingXXS}px;
|
||||
border: 1px ${token.colorSplit} solid;
|
||||
padding: ${token.padding}px ${token.paddingLG}px;
|
||||
}
|
||||
|
||||
& .tooltip-detail-footer {
|
||||
|
||||
@@ -86,7 +86,6 @@ export function EditableTitle({
|
||||
renderLink,
|
||||
maxWidth,
|
||||
autoSize = true,
|
||||
onEditingChange,
|
||||
...rest
|
||||
}: EditableTitleProps) {
|
||||
const [isEditing, setIsEditing] = useState(editing);
|
||||
@@ -132,8 +131,7 @@ export function EditableTitle({
|
||||
textArea.scrollTop = textArea.scrollHeight;
|
||||
}
|
||||
}
|
||||
onEditingChange?.(isEditing);
|
||||
}, [isEditing, onEditingChange]);
|
||||
}, [isEditing]);
|
||||
|
||||
function handleClick() {
|
||||
if (!canEdit || isEditing) return;
|
||||
|
||||
@@ -33,5 +33,4 @@ export interface EditableTitleProps {
|
||||
renderLink?: (title: string) => React.ReactNode;
|
||||
maxWidth?: number;
|
||||
autoSize?: boolean;
|
||||
onEditingChange?: (isEditing: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,24 +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 { Progress as AntdProgress } from 'antd';
|
||||
import type { ProgressProps as AntdProgressProps } from 'antd';
|
||||
|
||||
export type ProgressProps = AntdProgressProps;
|
||||
|
||||
export const Progress = AntdProgress;
|
||||
@@ -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>) => (
|
||||
|
||||
@@ -20,25 +20,25 @@
|
||||
import { fireEvent, render } from '@superset-ui/core/spec';
|
||||
import Tabs, { EditableTabs, LineEditableTabs } from './Tabs';
|
||||
|
||||
const defaultItems = [
|
||||
{
|
||||
key: '1',
|
||||
label: 'Tab 1',
|
||||
children: <div data-testid="tab1-content">Tab 1 content</div>,
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: 'Tab 2',
|
||||
children: <div data-testid="tab2-content">Tab 2 content</div>,
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: 'Tab 3',
|
||||
children: <div data-testid="tab3-content">Tab 3 content</div>,
|
||||
},
|
||||
];
|
||||
|
||||
describe('Tabs', () => {
|
||||
const defaultItems = [
|
||||
{
|
||||
key: '1',
|
||||
label: 'Tab 1',
|
||||
children: <div data-testid="tab1-content">Tab 1 content</div>,
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: 'Tab 2',
|
||||
children: <div data-testid="tab2-content">Tab 2 content</div>,
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: 'Tab 3',
|
||||
children: <div data-testid="tab3-content">Tab 3 content</div>,
|
||||
},
|
||||
];
|
||||
|
||||
describe('Basic Tabs', () => {
|
||||
it('should render tabs with default props', () => {
|
||||
const { getByText, container } = render(<Tabs items={defaultItems} />);
|
||||
@@ -284,7 +284,6 @@ describe('Tabs', () => {
|
||||
describe('Styling Integration', () => {
|
||||
it('should accept and apply custom CSS classes', () => {
|
||||
const { container } = render(
|
||||
// eslint-disable-next-line react/forbid-component-props
|
||||
<Tabs items={defaultItems} className="custom-tabs-class" />,
|
||||
);
|
||||
|
||||
@@ -296,7 +295,6 @@ describe('Tabs', () => {
|
||||
it('should accept and apply custom styles', () => {
|
||||
const customStyle = { minHeight: '200px' };
|
||||
const { container } = render(
|
||||
// eslint-disable-next-line react/forbid-component-props
|
||||
<Tabs items={defaultItems} style={customStyle} />,
|
||||
);
|
||||
|
||||
@@ -306,72 +304,3 @@ describe('Tabs', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('fullHeight prop renders component hierarchy correctly', () => {
|
||||
const { container } = render(<Tabs items={defaultItems} fullHeight />);
|
||||
|
||||
const tabsElement = container.querySelector('.ant-tabs');
|
||||
const contentHolder = container.querySelector('.ant-tabs-content-holder');
|
||||
const content = container.querySelector('.ant-tabs-content');
|
||||
const tabPane = container.querySelector('.ant-tabs-tabpane');
|
||||
|
||||
expect(tabsElement).toBeInTheDocument();
|
||||
expect(contentHolder).toBeInTheDocument();
|
||||
expect(content).toBeInTheDocument();
|
||||
expect(tabPane).toBeInTheDocument();
|
||||
expect(tabsElement?.contains(contentHolder as Node)).toBe(true);
|
||||
expect(contentHolder?.contains(content as Node)).toBe(true);
|
||||
expect(content?.contains(tabPane as Node)).toBe(true);
|
||||
});
|
||||
|
||||
test('fullHeight prop maintains structure when content updates', () => {
|
||||
const { container, rerender } = render(
|
||||
<Tabs items={defaultItems} fullHeight />,
|
||||
);
|
||||
|
||||
const initialTabsElement = container.querySelector('.ant-tabs');
|
||||
|
||||
const newItems = [
|
||||
...defaultItems,
|
||||
{
|
||||
key: '4',
|
||||
label: 'Tab 4',
|
||||
children: <div data-testid="tab4-content">New tab content</div>,
|
||||
},
|
||||
];
|
||||
|
||||
rerender(<Tabs items={newItems} fullHeight />);
|
||||
|
||||
const updatedTabsElement = container.querySelector('.ant-tabs');
|
||||
const updatedContentHolder = container.querySelector(
|
||||
'.ant-tabs-content-holder',
|
||||
);
|
||||
|
||||
expect(updatedTabsElement).toBeInTheDocument();
|
||||
expect(updatedContentHolder).toBeInTheDocument();
|
||||
expect(initialTabsElement).toBe(updatedTabsElement);
|
||||
});
|
||||
|
||||
test('fullHeight prop works with allowOverflow to handle tall content', () => {
|
||||
const { container } = render(
|
||||
<Tabs items={defaultItems} fullHeight allowOverflow />,
|
||||
);
|
||||
|
||||
const tabsElement = container.querySelector('.ant-tabs') as HTMLElement;
|
||||
const contentHolder = container.querySelector(
|
||||
'.ant-tabs-content-holder',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(tabsElement).toBeInTheDocument();
|
||||
expect(contentHolder).toBeInTheDocument();
|
||||
|
||||
// Verify overflow handling is not restricted
|
||||
const holderStyles = window.getComputedStyle(contentHolder);
|
||||
expect(holderStyles.overflow).not.toBe('hidden');
|
||||
});
|
||||
|
||||
test('fullHeight prop handles empty items array', () => {
|
||||
const { container } = render(<Tabs items={[]} fullHeight />);
|
||||
|
||||
expect(container.querySelector('.ant-tabs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -25,14 +25,12 @@ import type { SerializedStyles } from '@emotion/react';
|
||||
|
||||
export interface TabsProps extends AntdTabsProps {
|
||||
allowOverflow?: boolean;
|
||||
fullHeight?: boolean;
|
||||
contentStyle?: SerializedStyles;
|
||||
}
|
||||
|
||||
const StyledTabs = ({
|
||||
animated = false,
|
||||
allowOverflow = true,
|
||||
fullHeight = false,
|
||||
tabBarStyle,
|
||||
contentStyle,
|
||||
...props
|
||||
@@ -48,17 +46,9 @@ const StyledTabs = ({
|
||||
tabBarStyle={mergedStyle}
|
||||
css={theme => css`
|
||||
overflow: ${allowOverflow ? 'visible' : 'hidden'};
|
||||
${fullHeight && 'height: 100%;'}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
overflow: ${allowOverflow ? 'visible' : 'auto'};
|
||||
${fullHeight && 'height: 100%;'}
|
||||
}
|
||||
.ant-tabs-content {
|
||||
${fullHeight && 'height: 100%;'}
|
||||
}
|
||||
.ant-tabs-tabpane {
|
||||
${fullHeight && 'height: 100%;'}
|
||||
${contentStyle}
|
||||
}
|
||||
.ant-tabs-tab {
|
||||
|
||||
@@ -145,8 +145,6 @@ export {
|
||||
} from './ListViewCard';
|
||||
export { Loading, type LoadingProps } from './Loading';
|
||||
|
||||
export { Progress, type ProgressProps } from './Progress';
|
||||
|
||||
export { Skeleton, type SkeletonProps } from './Skeleton';
|
||||
|
||||
export { Switch, type SwitchProps } from './Switch';
|
||||
|
||||
@@ -19,27 +19,16 @@
|
||||
|
||||
import { DatasourceType } from './types/Datasource';
|
||||
|
||||
const DATASOURCE_TYPE_MAP: Record<string, DatasourceType> = {
|
||||
table: DatasourceType.Table,
|
||||
query: DatasourceType.Query,
|
||||
dataset: DatasourceType.Dataset,
|
||||
sl_table: DatasourceType.SlTable,
|
||||
saved_query: DatasourceType.SavedQuery,
|
||||
semantic_view: DatasourceType.SemanticView,
|
||||
};
|
||||
|
||||
export default class DatasourceKey {
|
||||
readonly id: number | string;
|
||||
readonly id: number;
|
||||
|
||||
readonly type: DatasourceType;
|
||||
|
||||
constructor(key: string) {
|
||||
const [idStr, typeStr] = key.split('__');
|
||||
// Only parse as integer if the entire string is numeric
|
||||
// (parseInt would incorrectly parse "85d3139f..." as 85)
|
||||
const isNumeric = /^\d+$/.test(idStr);
|
||||
this.id = isNumeric ? parseInt(idStr, 10) : idStr;
|
||||
this.type = DATASOURCE_TYPE_MAP[typeStr] ?? DatasourceType.Table;
|
||||
this.id = parseInt(idStr, 10);
|
||||
this.type = DatasourceType.Table; // default to SqlaTable model
|
||||
this.type = typeStr === 'query' ? DatasourceType.Query : this.type;
|
||||
}
|
||||
|
||||
public toString() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ export enum DatasourceType {
|
||||
Dataset = 'dataset',
|
||||
SlTable = 'sl_table',
|
||||
SavedQuery = 'saved_query',
|
||||
SemanticView = 'semantic_view',
|
||||
}
|
||||
|
||||
export interface Currency {
|
||||
@@ -38,7 +37,7 @@ export interface Currency {
|
||||
* Datasource metadata.
|
||||
*/
|
||||
export interface Datasource {
|
||||
id: number | string;
|
||||
id: number;
|
||||
name: string;
|
||||
type: DatasourceType;
|
||||
columns: Column[];
|
||||
|
||||
@@ -156,7 +156,7 @@ export interface QueryObject
|
||||
|
||||
export interface QueryContext {
|
||||
datasource: {
|
||||
id: number | string;
|
||||
id: number;
|
||||
type: DatasourceType;
|
||||
};
|
||||
/** Force refresh of all queries */
|
||||
|
||||
@@ -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".
|
||||
@@ -31,7 +31,6 @@ const propTypes = {
|
||||
data: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
country: PropTypes.string,
|
||||
code: PropTypes.string,
|
||||
latitude: PropTypes.number,
|
||||
longitude: PropTypes.number,
|
||||
name: PropTypes.string,
|
||||
@@ -117,7 +116,7 @@ function WorldMap(element, props) {
|
||||
const selected = Object.values(filterState.selectedValues || {});
|
||||
const key = source.id || source.country;
|
||||
const country =
|
||||
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.code;
|
||||
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country;
|
||||
|
||||
if (!country) {
|
||||
return undefined;
|
||||
@@ -171,7 +170,7 @@ function WorldMap(element, props) {
|
||||
pointerEvent.preventDefault();
|
||||
const key = source.id || source.country;
|
||||
const val =
|
||||
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.code;
|
||||
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country;
|
||||
let drillToDetailFilters;
|
||||
let drillByFilters;
|
||||
if (val) {
|
||||
|
||||
@@ -1,311 +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,
|
||||
cleanup,
|
||||
} from '../../../../spec/helpers/testing-library';
|
||||
import { AxisType } from '@superset-ui/core';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
LegendOrientation,
|
||||
LegendType,
|
||||
type EchartsHandler,
|
||||
type EchartsProps,
|
||||
} from '../types';
|
||||
import EchartsTimeseries from './EchartsTimeseries';
|
||||
import {
|
||||
EchartsTimeseriesSeriesType,
|
||||
OrientationType,
|
||||
type EchartsTimeseriesFormData,
|
||||
type TimeseriesChartTransformedProps,
|
||||
} from './types';
|
||||
|
||||
const mockEchart = jest.fn();
|
||||
|
||||
jest.mock('../components/Echart', () => {
|
||||
const { forwardRef } = jest.requireActual<typeof import('react')>('react');
|
||||
const MockEchart = forwardRef<EchartsHandler | null, EchartsProps>(
|
||||
(props, ref) => {
|
||||
mockEchart(props);
|
||||
void ref;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
MockEchart.displayName = 'MockEchart';
|
||||
return {
|
||||
__esModule: true,
|
||||
default: MockEchart,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../components/ExtraControls', () => ({
|
||||
ExtraControls: ({ children }: { children?: ReactNode }) => (
|
||||
<div data-testid="extra-controls">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
'offsetHeight',
|
||||
);
|
||||
|
||||
let mockOffsetHeight = 0;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return mockOffsetHeight;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (offsetHeightDescriptor) {
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
'offsetHeight',
|
||||
offsetHeightDescriptor,
|
||||
);
|
||||
} else {
|
||||
delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockEchart.mockReset();
|
||||
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
|
||||
originalResizeObserver;
|
||||
});
|
||||
|
||||
const defaultFormData: EchartsTimeseriesFormData & {
|
||||
vizType: string;
|
||||
dateFormat: string;
|
||||
numberFormat: string;
|
||||
granularitySqla?: string;
|
||||
} = {
|
||||
annotationLayers: [],
|
||||
area: false,
|
||||
colorScheme: undefined,
|
||||
timeShiftColor: false,
|
||||
contributionMode: undefined,
|
||||
forecastEnabled: false,
|
||||
forecastPeriods: 0,
|
||||
forecastInterval: 0,
|
||||
forecastSeasonalityDaily: null,
|
||||
forecastSeasonalityWeekly: null,
|
||||
forecastSeasonalityYearly: null,
|
||||
logAxis: false,
|
||||
markerEnabled: false,
|
||||
markerSize: 1,
|
||||
metrics: [],
|
||||
minorSplitLine: false,
|
||||
minorTicks: false,
|
||||
opacity: 1,
|
||||
orderDesc: false,
|
||||
rowLimit: 0,
|
||||
seriesType: EchartsTimeseriesSeriesType.Line,
|
||||
stack: null,
|
||||
stackDimension: '',
|
||||
timeCompare: [],
|
||||
tooltipTimeFormat: undefined,
|
||||
showTooltipTotal: false,
|
||||
showTooltipPercentage: false,
|
||||
truncateXAxis: false,
|
||||
truncateYAxis: false,
|
||||
yAxisFormat: undefined,
|
||||
xAxisForceCategorical: false,
|
||||
xAxisTimeFormat: undefined,
|
||||
timeGrainSqla: undefined,
|
||||
forceMaxInterval: false,
|
||||
xAxisBounds: [null, null],
|
||||
yAxisBounds: [null, null],
|
||||
zoomable: false,
|
||||
richTooltip: false,
|
||||
xAxisLabelRotation: 0,
|
||||
xAxisLabelInterval: 0,
|
||||
showValue: false,
|
||||
onlyTotal: false,
|
||||
showExtraControls: true,
|
||||
percentageThreshold: 0,
|
||||
orientation: OrientationType.Vertical,
|
||||
datasource: '1__table',
|
||||
viz_type: 'echarts_timeseries',
|
||||
legendMargin: 0,
|
||||
legendOrientation: LegendOrientation.Top,
|
||||
legendType: LegendType.Plain,
|
||||
showLegend: false,
|
||||
legendSort: null,
|
||||
xAxisTitle: '',
|
||||
xAxisTitleMargin: 0,
|
||||
yAxisTitle: '',
|
||||
yAxisTitleMargin: 0,
|
||||
yAxisTitlePosition: '',
|
||||
time_range: 'No filter',
|
||||
granularity: undefined,
|
||||
granularity_sqla: undefined,
|
||||
sql: '',
|
||||
url_params: {},
|
||||
custom_params: {},
|
||||
extra_form_data: {},
|
||||
adhoc_filters: [],
|
||||
order_desc: false,
|
||||
row_limit: 0,
|
||||
row_offset: 0,
|
||||
time_grain_sqla: undefined,
|
||||
vizType: 'echarts_timeseries',
|
||||
dateFormat: 'smart_date',
|
||||
numberFormat: 'SMART_NUMBER',
|
||||
};
|
||||
|
||||
const defaultProps: TimeseriesChartTransformedProps = {
|
||||
echartOptions: {} as EChartsCoreOption,
|
||||
formData: defaultFormData,
|
||||
height: 400,
|
||||
width: 800,
|
||||
onContextMenu: jest.fn(),
|
||||
setDataMask: jest.fn(),
|
||||
onLegendStateChanged: jest.fn(),
|
||||
refs: {},
|
||||
emitCrossFilters: false,
|
||||
coltypeMapping: {},
|
||||
onLegendScroll: jest.fn(),
|
||||
groupby: [],
|
||||
labelMap: {},
|
||||
setControlValue: jest.fn(),
|
||||
selectedValues: {},
|
||||
legendData: [],
|
||||
xValueFormatter: String,
|
||||
xAxis: {
|
||||
label: 'x',
|
||||
type: AxisType.Time,
|
||||
},
|
||||
onFocusedSeries: jest.fn(),
|
||||
};
|
||||
|
||||
function getLatestHeight() {
|
||||
const lastCall = mockEchart.mock.calls.at(-1);
|
||||
expect(lastCall).toBeDefined();
|
||||
const [props] = lastCall as [EchartsProps];
|
||||
return props.height;
|
||||
}
|
||||
|
||||
test('observes extra control height changes when ResizeObserver is available', async () => {
|
||||
const disconnectSpy = jest.fn();
|
||||
const observeSpy = jest.fn();
|
||||
|
||||
class MockResizeObserver implements ResizeObserver {
|
||||
private static latestInstance: MockResizeObserver | null = null;
|
||||
private readonly callback: ResizeObserverCallback;
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
MockResizeObserver.latestInstance = this;
|
||||
}
|
||||
|
||||
observe = (target: Element) => {
|
||||
observeSpy(target);
|
||||
};
|
||||
|
||||
unobserve(_target: Element): void {
|
||||
void _target;
|
||||
}
|
||||
|
||||
disconnect = () => {
|
||||
disconnectSpy();
|
||||
};
|
||||
|
||||
trigger(entries: ResizeObserverEntry[] = []) {
|
||||
this.callback(entries, this);
|
||||
}
|
||||
|
||||
static getLatestInstance() {
|
||||
return this.latestInstance;
|
||||
}
|
||||
}
|
||||
|
||||
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
|
||||
MockResizeObserver as unknown as typeof ResizeObserver;
|
||||
|
||||
mockOffsetHeight = 42;
|
||||
const { unmount } = render(<EchartsTimeseries {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getLatestHeight()).toBe(defaultProps.height - mockOffsetHeight);
|
||||
});
|
||||
|
||||
expect(observeSpy).toHaveBeenCalledWith(expect.any(HTMLElement));
|
||||
|
||||
mockOffsetHeight = 24;
|
||||
MockResizeObserver.getLatestInstance()?.trigger();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getLatestHeight()).toBe(defaultProps.height - mockOffsetHeight);
|
||||
});
|
||||
|
||||
expect(disconnectSpy).not.toHaveBeenCalled();
|
||||
|
||||
expect(MockResizeObserver.getLatestInstance()).not.toBeNull();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('falls back to window resize listener when ResizeObserver is unavailable', async () => {
|
||||
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
|
||||
undefined;
|
||||
|
||||
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
|
||||
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
|
||||
|
||||
mockOffsetHeight = 30;
|
||||
|
||||
const { unmount } = render(<EchartsTimeseries {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getLatestHeight()).toBe(defaultProps.height - mockOffsetHeight);
|
||||
});
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'resize',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
mockOffsetHeight = 10;
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getLatestHeight()).toBe(defaultProps.height - mockOffsetHeight);
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'resize',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
addEventListenerSpy.mockRestore();
|
||||
removeEventListenerSpy.mockRestore();
|
||||
});
|
||||
@@ -67,32 +67,8 @@ export default function EchartsTimeseries({
|
||||
const extraControlRef = useRef<HTMLDivElement>(null);
|
||||
const [extraControlHeight, setExtraControlHeight] = useState(0);
|
||||
useEffect(() => {
|
||||
const element = extraControlRef.current;
|
||||
if (!element) {
|
||||
setExtraControlHeight(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateHeight = () => {
|
||||
setExtraControlHeight(element.offsetHeight || 0);
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateHeight();
|
||||
});
|
||||
resizeObserver.observe(element);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateHeight);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateHeight);
|
||||
};
|
||||
const updatedHeight = extraControlRef.current?.offsetHeight || 0;
|
||||
setExtraControlHeight(updatedHeight);
|
||||
}, [formData.showExtraControls]);
|
||||
|
||||
const hasDimensions = ensureIsArray(groupby).length > 0;
|
||||
|
||||
@@ -112,53 +112,6 @@ const config: ControlPanelConfig = {
|
||||
...sharedControls.x_axis_time_format,
|
||||
default: 'smart_date',
|
||||
description: `${D3_TIME_FORMAT_DOCS}. ${TIME_SERIES_DESCRIPTION_TEXT}`,
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) => {
|
||||
// check if x axis is a time column
|
||||
const xAxisColumn = controls?.x_axis?.value;
|
||||
const xAxisOptions = controls?.x_axis?.options;
|
||||
|
||||
if (!xAxisColumn || !Array.isArray(xAxisOptions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const xAxisType = xAxisOptions.find(
|
||||
option => option.column_name === xAxisColumn,
|
||||
)?.type;
|
||||
|
||||
return (
|
||||
typeof xAxisType === 'string' &&
|
||||
xAxisType.toUpperCase().includes('TIME')
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'x_axis_number_format',
|
||||
config: {
|
||||
...sharedControls.x_axis_number_format,
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) => {
|
||||
// check if x axis is a floating-point column
|
||||
const xAxisColumn = controls?.x_axis?.value;
|
||||
const xAxisOptions = controls?.x_axis?.options;
|
||||
|
||||
if (!xAxisColumn || !Array.isArray(xAxisOptions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const xAxisType = xAxisOptions.find(
|
||||
option => option.column_name === xAxisColumn,
|
||||
)?.type;
|
||||
|
||||
if (typeof xAxisType !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const typeUpper = xAxisType.toUpperCase();
|
||||
|
||||
return ['FLOAT', 'DOUBLE', 'REAL', 'NUMERIC', 'DECIMAL'].some(
|
||||
t => typeUpper.includes(t),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -72,7 +72,6 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
|
||||
stack: false,
|
||||
tooltipTimeFormat: 'smart_date',
|
||||
xAxisTimeFormat: 'smart_date',
|
||||
xAxisNumberFormat: 'SMART_NUMBER',
|
||||
truncateXAxis: true,
|
||||
truncateYAxis: false,
|
||||
yAxisBounds: [null, null],
|
||||
|
||||
@@ -189,7 +189,6 @@ export default function transformProps(
|
||||
xAxisSort,
|
||||
xAxisSortAsc,
|
||||
xAxisTimeFormat,
|
||||
xAxisNumberFormat,
|
||||
xAxisTitle,
|
||||
xAxisTitleMargin,
|
||||
yAxisBounds,
|
||||
@@ -486,9 +485,7 @@ export default function transformProps(
|
||||
const xAxisFormatter =
|
||||
xAxisDataType === GenericDataType.Temporal
|
||||
? getXAxisFormatter(xAxisTimeFormat)
|
||||
: xAxisDataType === GenericDataType.Numeric
|
||||
? getNumberFormatter(xAxisNumberFormat)
|
||||
: String;
|
||||
: String;
|
||||
|
||||
const {
|
||||
setDataMask = () => {},
|
||||
|
||||
@@ -84,7 +84,6 @@ export type EchartsTimeseriesFormData = QueryFormData & {
|
||||
yAxisFormat?: string;
|
||||
xAxisForceCategorical?: boolean;
|
||||
xAxisTimeFormat?: string;
|
||||
xAxisNumberFormat?: string;
|
||||
timeGrainSqla?: TimeGranularity;
|
||||
forceMaxInterval?: boolean;
|
||||
xAxisBounds: [number | undefined | null, number | undefined | null];
|
||||
|
||||
@@ -42,5 +42,4 @@ export const DEFAULT_FORM_DATA: Partial<EchartsTreeFormData> = {
|
||||
nodeLabelPosition: 'left',
|
||||
childLabelPosition: 'bottom',
|
||||
emphasis: 'descendant',
|
||||
initialTreeDepth: 2,
|
||||
};
|
||||
|
||||
@@ -279,23 +279,6 @@ const controlPanel: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'initialTreeDepth',
|
||||
config: {
|
||||
type: 'NumberControl',
|
||||
label: t('Initial tree depth'),
|
||||
min: -1,
|
||||
step: 1,
|
||||
max: 10,
|
||||
default: DEFAULT_FORM_DATA.initialTreeDepth,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'The initial level (depth) of the tree. If set as -1 all nodes are expanded.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -74,7 +74,6 @@ export default function transformProps(
|
||||
nodeLabelPosition,
|
||||
childLabelPosition,
|
||||
emphasis,
|
||||
initialTreeDepth,
|
||||
}: EchartsTreeFormData = { ...DEFAULT_FORM_DATA, ...formData };
|
||||
const metricLabel = getMetricLabel(metric);
|
||||
|
||||
@@ -204,7 +203,6 @@ export default function transformProps(
|
||||
},
|
||||
select: DEFAULT_TREE_SERIES_OPTION.select,
|
||||
leaves: { label: { position: childLabelPosition } },
|
||||
initialTreeDepth,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ export type EchartsTreeFormData = QueryFormData & {
|
||||
nodeLabelPosition: 'top' | 'bottom' | 'left' | 'right';
|
||||
childLabelPosition: 'top' | 'bottom' | 'left' | 'right';
|
||||
emphasis: 'none' | 'ancestor' | 'descendant';
|
||||
initialTreeDepth: number;
|
||||
};
|
||||
|
||||
export interface TreeChartDataResponseResult extends ChartDataResponseResult {
|
||||
|
||||
@@ -67,7 +67,7 @@ const legendTypeControl: ControlSetItem = {
|
||||
label: t('Type'),
|
||||
choices: [
|
||||
['scroll', t('Scroll')],
|
||||
['plain', t('List')],
|
||||
['plain', t('Plain')],
|
||||
],
|
||||
default: legendType,
|
||||
renderTrigger: true,
|
||||
|
||||
@@ -482,17 +482,8 @@ export function getLegendProps(
|
||||
break;
|
||||
case LegendOrientation.Bottom:
|
||||
legend.bottom = 0;
|
||||
if (padding?.left) {
|
||||
legend.left = padding.left;
|
||||
}
|
||||
break;
|
||||
case LegendOrientation.Top:
|
||||
legend.top = 0;
|
||||
legend.right = zoomable ? TIMESERIES_CONSTANTS.legendTopRightOffset : 0;
|
||||
if (padding?.left) {
|
||||
legend.left = padding.left;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
legend.top = 0;
|
||||
legend.right = zoomable ? TIMESERIES_CONSTANTS.legendTopRightOffset : 0;
|
||||
|
||||
@@ -1,156 +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 { ControlPanelsContainerProps } from '@superset-ui/chart-controls/types';
|
||||
import controlPanel from '../../../src/Timeseries/Regular/Scatter/controlPanel';
|
||||
|
||||
const config = controlPanel;
|
||||
|
||||
const getControl = (controlName: string) => {
|
||||
for (const section of config.controlPanelSections) {
|
||||
if (section && section.controlSetRows) {
|
||||
for (const row of section.controlSetRows) {
|
||||
for (const control of row) {
|
||||
if (
|
||||
typeof control === 'object' &&
|
||||
control !== null &&
|
||||
'name' in control &&
|
||||
control.name === controlName
|
||||
) {
|
||||
return control;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const mockControls = (
|
||||
xAxisColumn: string | null,
|
||||
xAxisType: string | null,
|
||||
): ControlPanelsContainerProps => {
|
||||
const options = xAxisType
|
||||
? [{ column_name: xAxisColumn, type: xAxisType }]
|
||||
: [];
|
||||
|
||||
return {
|
||||
controls: {
|
||||
// @ts-ignore
|
||||
x_axis: {
|
||||
value: xAxisColumn,
|
||||
options: options,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// tests for x_axis_time_format control
|
||||
const timeFormatControl: any = getControl('x_axis_time_format');
|
||||
|
||||
test('scatter chart control panel should include x_axis_time_format control in the panel', () => {
|
||||
expect(timeFormatControl).toBeDefined();
|
||||
});
|
||||
|
||||
test('scatter chart control panel should have correct default value for x_axis_time_format', () => {
|
||||
expect(timeFormatControl).toBeDefined();
|
||||
expect(timeFormatControl.config).toBeDefined();
|
||||
expect(timeFormatControl.config.default).toBe('smart_date');
|
||||
});
|
||||
|
||||
test('scatter chart control panel should have visibility function for x_axis_time_format', () => {
|
||||
expect(timeFormatControl).toBeDefined();
|
||||
expect(timeFormatControl.config.visibility).toBeDefined();
|
||||
expect(typeof timeFormatControl.config.visibility).toBe('function');
|
||||
|
||||
// The visibility function exists - the exact logic is tested implicitly through UI behavior
|
||||
// The important part is that the control has proper visibility configuration
|
||||
});
|
||||
|
||||
const isTimeVisible = (
|
||||
xAxisColumn: string | null,
|
||||
xAxisType: string | null,
|
||||
): boolean => {
|
||||
const props = mockControls(xAxisColumn, xAxisType);
|
||||
const visibilityFn = timeFormatControl?.config?.visibility;
|
||||
return visibilityFn ? visibilityFn(props) : false;
|
||||
};
|
||||
|
||||
test('x_axis_time_format control should be visible for any data types include TIME', () => {
|
||||
expect(isTimeVisible('time_column', 'TIME')).toBe(true);
|
||||
expect(isTimeVisible('time_column', 'TIME WITH TIME ZONE')).toBe(true);
|
||||
expect(isTimeVisible('time_column', 'TIMESTAMP WITH TIME ZONE')).toBe(true);
|
||||
expect(isTimeVisible('time_column', 'TIMESTAMP WITHOUT TIME ZONE')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('x_axis_time_format control should be hidden for data types that do NOT include TIME', () => {
|
||||
expect(isTimeVisible('null', 'null')).toBe(false);
|
||||
expect(isTimeVisible(null, null)).toBe(false);
|
||||
expect(isTimeVisible('float_column', 'FLOAT')).toBe(false);
|
||||
});
|
||||
|
||||
// tests for x_axis_number_format control
|
||||
const numberFormatControl: any = getControl('x_axis_number_format');
|
||||
|
||||
test('scatter chart control panel should include x_axis_number_format control in the panel', () => {
|
||||
expect(numberFormatControl).toBeDefined();
|
||||
});
|
||||
|
||||
test('scatter chart control panel should have correct default value for x_axis_number_format', () => {
|
||||
expect(numberFormatControl).toBeDefined();
|
||||
expect(numberFormatControl.config).toBeDefined();
|
||||
expect(numberFormatControl.config.default).toBe('SMART_NUMBER');
|
||||
});
|
||||
|
||||
test('scatter chart control panel should have visibility function for x_axis_number_format', () => {
|
||||
expect(numberFormatControl).toBeDefined();
|
||||
expect(numberFormatControl.config.visibility).toBeDefined();
|
||||
expect(typeof numberFormatControl.config.visibility).toBe('function');
|
||||
|
||||
// The visibility function exists - the exact logic is tested implicitly through UI behavior
|
||||
// The important part is that the control has proper visibility configuration
|
||||
});
|
||||
|
||||
const isNumberVisible = (
|
||||
xAxisColumn: string | null,
|
||||
xAxisType: string | null,
|
||||
): boolean => {
|
||||
const props = mockControls(xAxisColumn, xAxisType);
|
||||
const visibilityFn = numberFormatControl?.config?.visibility;
|
||||
return visibilityFn ? visibilityFn(props) : false;
|
||||
};
|
||||
|
||||
test('x_axis_number_format control should be visible for any floating-point data types', () => {
|
||||
expect(isNumberVisible('float_column', 'FLOAT')).toBe(true);
|
||||
expect(isNumberVisible('double_column', 'DOUBLE')).toBe(true);
|
||||
expect(isNumberVisible('real_column', 'REAL')).toBe(true);
|
||||
expect(isNumberVisible('numeric_column', 'NUMERIC')).toBe(true);
|
||||
expect(isNumberVisible('decimal_column', 'DECIMAL')).toBe(true);
|
||||
});
|
||||
|
||||
test('x_axis_number_format control should be hidden for any non-floating-point data types', () => {
|
||||
expect(isNumberVisible('string_column', 'VARCHAR')).toBe(false);
|
||||
expect(isNumberVisible('null', 'null')).toBe(false);
|
||||
expect(isNumberVisible(null, null)).toBe(false);
|
||||
expect(isNumberVisible('time_column', 'TIMESTAMP WITHOUT TIME ZONE')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -1,183 +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 { ChartProps, SMART_DATE_ID } from '@superset-ui/core';
|
||||
import transformProps from '../../../src/Timeseries/transformProps';
|
||||
import { DEFAULT_FORM_DATA } from '../../../src/Timeseries/constants';
|
||||
import {
|
||||
EchartsTimeseriesSeriesType,
|
||||
EchartsTimeseriesFormData,
|
||||
EchartsTimeseriesChartProps,
|
||||
} from '../../../src/Timeseries/types';
|
||||
import { GenericDataType } from '@apache-superset/core/api/core';
|
||||
import {
|
||||
D3_FORMAT_OPTIONS,
|
||||
D3_TIME_FORMAT_OPTIONS,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
|
||||
describe('Scatter Chart X-axis Time Formatting', () => {
|
||||
const baseFormData: EchartsTimeseriesFormData = {
|
||||
...DEFAULT_FORM_DATA,
|
||||
colorScheme: 'supersetColors',
|
||||
datasource: '1__table',
|
||||
granularity_sqla: '__timestamp',
|
||||
metric: ['column 1'],
|
||||
groupby: [],
|
||||
viz_type: 'echarts_timeseries_scatter',
|
||||
seriesType: EchartsTimeseriesSeriesType.Scatter,
|
||||
};
|
||||
|
||||
const timeseriesData = [
|
||||
{
|
||||
data: [
|
||||
{ column_1: 0.72099, __timestamp: 1609459200000 },
|
||||
{ column_1: 0.77954, __timestamp: 1612137600000 },
|
||||
{ column_1: 2.83434, __timestamp: 1614556800000 },
|
||||
],
|
||||
colnames: ['column_1', '__timestamp'],
|
||||
coltypes: [GenericDataType.Numeric, GenericDataType.Temporal],
|
||||
},
|
||||
];
|
||||
|
||||
const baseChartPropsConfig = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: timeseriesData,
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
test('xAxisTimeFormat has no default formatter', () => {
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: baseFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
// @ts-ignore
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(xAxis.axisLabel.formatter).toBeUndefined();
|
||||
});
|
||||
|
||||
test.each(D3_TIME_FORMAT_OPTIONS.map(([id]) => id))(
|
||||
'should handle %s format',
|
||||
format => {
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: format,
|
||||
},
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
// @ts-ignore
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
if (format === SMART_DATE_ID) {
|
||||
expect(xAxis.axisLabel.formatter).toBeUndefined();
|
||||
} else {
|
||||
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||
expect(xAxis.axisLabel.formatter.id).toBe(format);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Scatter Chart X-axis Number Formatting', () => {
|
||||
const baseFormData: EchartsTimeseriesFormData = {
|
||||
...DEFAULT_FORM_DATA,
|
||||
colorScheme: 'supersetColors',
|
||||
datasource: '1__table',
|
||||
metric: ['column_1'],
|
||||
x_axis: 'column_2',
|
||||
groupby: [],
|
||||
viz_type: 'echarts_timeseries_scatter',
|
||||
seriesType: EchartsTimeseriesSeriesType.Scatter,
|
||||
};
|
||||
|
||||
const timeseriesData = [
|
||||
{
|
||||
data: [
|
||||
{ column_1: 0.72099, column_2: 3.01699 },
|
||||
{ column_1: 0.77954, column_2: 3.44802 },
|
||||
{ column_1: 2.83434, column_2: 3.58095 },
|
||||
],
|
||||
colnames: ['column_1', 'column_2'],
|
||||
coltypes: [GenericDataType.Numeric, GenericDataType.Numeric],
|
||||
},
|
||||
];
|
||||
|
||||
const baseChartPropsConfig = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: timeseriesData,
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
test('should use SMART_NUMBER as default xAxisNumberFormat', () => {
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: baseFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
// @ts-ignore
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||
expect(xAxis.axisLabel.formatter.id).toBe('SMART_NUMBER');
|
||||
});
|
||||
|
||||
test.each(D3_FORMAT_OPTIONS.map(([id]) => id))(
|
||||
'should handle %s format',
|
||||
format => {
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: {
|
||||
...baseFormData,
|
||||
xAxisNumberFormat: format,
|
||||
},
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
// @ts-ignore
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||
expect(xAxis.axisLabel.formatter.id).toBe(format);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,7 +166,7 @@ function StickyWrap({
|
||||
const scrollBodyRef = useRef<HTMLDivElement>(null); // main body
|
||||
|
||||
const scrollBarSize = getScrollBarSize();
|
||||
const { bodyHeight, columnWidths, hasVerticalScroll } = sticky;
|
||||
const { bodyHeight, columnWidths } = sticky;
|
||||
const needSizer =
|
||||
!columnWidths ||
|
||||
sticky.width !== maxWidth ||
|
||||
@@ -283,18 +283,13 @@ function StickyWrap({
|
||||
</colgroup>
|
||||
);
|
||||
|
||||
const headerContainerWidth = hasVerticalScroll
|
||||
? maxWidth - scrollBarSize
|
||||
: maxWidth;
|
||||
|
||||
headerTable = (
|
||||
<div
|
||||
key="header"
|
||||
ref={scrollHeaderRef}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
width: headerContainerWidth,
|
||||
boxSizing: 'border-box',
|
||||
scrollbarGutter: 'stable',
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
@@ -314,8 +309,7 @@ function StickyWrap({
|
||||
ref={scrollFooterRef}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
width: headerContainerWidth,
|
||||
boxSizing: 'border-box',
|
||||
scrollbarGutter: 'stable',
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
@@ -345,8 +339,6 @@ function StickyWrap({
|
||||
height: bodyHeight,
|
||||
overflow: 'auto',
|
||||
scrollbarGutter: 'stable',
|
||||
width: maxWidth,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
css={scrollBarStyles}
|
||||
onScroll={sticky.hasHorizontalScroll ? onScroll : undefined}
|
||||
|
||||
@@ -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,120 +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';
|
||||
|
||||
const expectValidAriaLabels = (container: HTMLElement) => {
|
||||
const allCells = container.querySelectorAll('tbody td');
|
||||
const cellsWithLabels = container.querySelectorAll(
|
||||
'tbody td[aria-labelledby]',
|
||||
);
|
||||
|
||||
// Table must render data cells (catch empty table regression)
|
||||
expect(allCells.length).toBeGreaterThan(0);
|
||||
|
||||
// ALL data cells must have aria-labelledby (no unlabeled cells)
|
||||
expect(cellsWithLabels.length).toBe(allCells.length);
|
||||
|
||||
// ALL aria-labelledby values should be valid
|
||||
cellsWithLabels.forEach(cell => {
|
||||
const labelledBy = cell.getAttribute('aria-labelledby');
|
||||
expect(labelledBy).not.toBeNull();
|
||||
expect(labelledBy).toEqual(expect.stringMatching(/\S/));
|
||||
const labelledByValue = labelledBy as string;
|
||||
expect(labelledByValue).not.toMatch(/\s/);
|
||||
expect(labelledByValue).not.toMatch(/[%#△]/);
|
||||
const referencedHeader = container.querySelector(
|
||||
`#${CSS.escape(labelledByValue)}`,
|
||||
);
|
||||
expect(referencedHeader).toBeTruthy();
|
||||
});
|
||||
};
|
||||
|
||||
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({
|
||||
@@ -147,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,
|
||||
@@ -162,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 =>
|
||||
@@ -191,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: {
|
||||
@@ -214,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(
|
||||
@@ -252,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 =>
|
||||
@@ -283,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
|
||||
@@ -370,7 +265,7 @@ describe('plugin-chart-table', () => {
|
||||
});
|
||||
|
||||
describe('TableChart', () => {
|
||||
test('render basic data', () => {
|
||||
it('render basic data', () => {
|
||||
render(
|
||||
<TableChart {...transformProps(testData.basic)} sticky={false} />,
|
||||
);
|
||||
@@ -389,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();
|
||||
@@ -406,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: (
|
||||
@@ -426,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: (
|
||||
@@ -447,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 },
|
||||
@@ -464,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: {
|
||||
@@ -489,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: {
|
||||
@@ -531,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: (
|
||||
@@ -568,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',
|
||||
@@ -609,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} />);
|
||||
@@ -624,128 +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);
|
||||
|
||||
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_-]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
test('should validate ARIA references for time-comparison table cells', () => {
|
||||
// Test that ALL cells with aria-labelledby have valid references
|
||||
// This is critical for screen reader accessibility
|
||||
const props = transformProps(testData.comparison);
|
||||
|
||||
const { container } = render(<TableChart {...props} sticky={false} />);
|
||||
|
||||
expectValidAriaLabels(container);
|
||||
});
|
||||
|
||||
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('should validate ARIA references for regular table cells', () => {
|
||||
// Test that ALL cells with aria-labelledby have valid references
|
||||
// This is critical for screen reader accessibility
|
||||
const props = transformProps(testData.advanced);
|
||||
|
||||
const { container } = render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
|
||||
expectValidAriaLabels(container);
|
||||
});
|
||||
|
||||
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 },
|
||||
@@ -795,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: (
|
||||
@@ -827,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: (
|
||||
@@ -856,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: (
|
||||
@@ -885,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: (
|
||||
@@ -916,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: (
|
||||
@@ -947,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: (
|
||||
@@ -980,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: (
|
||||
@@ -1016,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: (
|
||||
@@ -1049,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
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,8 +83,6 @@ import {
|
||||
} from 'src/logger/LogUtils';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import { StreamingExportModal } from 'src/components/StreamingExportModal';
|
||||
import { useStreamingExport } from 'src/components/StreamingExportModal/useStreamingExport';
|
||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
||||
import { useConfirmModal } from 'src/hooks/useConfirmModal';
|
||||
import ExploreCtasResultsButton from '../ExploreCtasResultsButton';
|
||||
@@ -186,10 +184,6 @@ const ResultSet = ({
|
||||
defaultQueryLimit,
|
||||
}: ResultSetProps) => {
|
||||
const user = useSelector(({ user }: SqlLabRootState) => user, shallowEqual);
|
||||
const streamingThreshold = useSelector(
|
||||
(state: SqlLabRootState) =>
|
||||
state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD || 1000,
|
||||
);
|
||||
const query = useSelector(
|
||||
({ sqlLab: { queries } }: SqlLabRootState) =>
|
||||
pick(queries[queryId], [
|
||||
@@ -230,21 +224,12 @@ const ResultSet = ({
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [cachedData, setCachedData] = useState<Record<string, unknown>[]>([]);
|
||||
const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
|
||||
const [showStreamingModal, setShowStreamingModal] = useState(false);
|
||||
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch();
|
||||
const logAction = useLogAction({ queryId, sqlEditorId: query.sqlEditorId });
|
||||
const { showConfirm, ConfirmModal } = useConfirmModal();
|
||||
|
||||
const { progress, startExport, resetExport, retryExport, cancelExport } =
|
||||
useStreamingExport({
|
||||
onComplete: () => {},
|
||||
onError: error => {
|
||||
addDangerToast(t('Export failed: %s', error));
|
||||
},
|
||||
});
|
||||
|
||||
const reRunQueryIfSessionTimeoutErrorOnMount = useCallback(() => {
|
||||
if (
|
||||
query.errorMessage &&
|
||||
@@ -317,28 +302,6 @@ const ResultSet = ({
|
||||
const getExportCsvUrl = (clientId: string) =>
|
||||
ensureAppRoot(`/api/v1/sqllab/export/${clientId}/`);
|
||||
|
||||
const handleCloseStreamingModal = () => {
|
||||
cancelExport();
|
||||
setShowStreamingModal(false);
|
||||
resetExport();
|
||||
};
|
||||
|
||||
const shouldUseStreamingExport = () => {
|
||||
const { rows, queryLimit, limitingFactor } = query;
|
||||
const limit = queryLimit || query.results?.query?.limit;
|
||||
const rowsCount = Math.min(rows || 0, query.results?.data?.length || 0);
|
||||
|
||||
let actualRowCount = rowsCount;
|
||||
|
||||
if (limitingFactor === LimitingFactor.NotLimited && rows) {
|
||||
actualRowCount = rows;
|
||||
} else if (limit) {
|
||||
actualRowCount = Math.max(actualRowCount, limit);
|
||||
}
|
||||
|
||||
return actualRowCount >= streamingThreshold;
|
||||
};
|
||||
|
||||
const renderControls = () => {
|
||||
if (search || visualize || csv) {
|
||||
const { limitingFactor, queryLimit, results, rows } = query;
|
||||
@@ -409,27 +372,9 @@ const ResultSet = ({
|
||||
<CopyStyledButton
|
||||
buttonSize="small"
|
||||
buttonStyle="secondary"
|
||||
{...(!shouldUseStreamingExport() && {
|
||||
href: getExportCsvUrl(query.id),
|
||||
})}
|
||||
href={getExportCsvUrl(query.id)}
|
||||
data-test="export-csv-button"
|
||||
onClick={e => {
|
||||
const useStreaming = shouldUseStreamingExport();
|
||||
|
||||
if (useStreaming) {
|
||||
e.preventDefault();
|
||||
setShowStreamingModal(true);
|
||||
|
||||
startExport({
|
||||
url: '/api/v1/sqllab/export_streaming/',
|
||||
payload: { client_id: query.id },
|
||||
exportType: 'csv',
|
||||
expectedRows: rows,
|
||||
});
|
||||
} else {
|
||||
handleDownloadCsv(e);
|
||||
}
|
||||
}}
|
||||
onClick={handleDownloadCsv}
|
||||
>
|
||||
<Icons.DownloadOutlined iconSize="m" /> {t('Download to CSV')}
|
||||
</CopyStyledButton>
|
||||
@@ -778,75 +723,43 @@ const ResultSet = ({
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</ResultContainer>
|
||||
<StreamingExportModal
|
||||
visible={showStreamingModal}
|
||||
onCancel={handleCloseStreamingModal}
|
||||
onRetry={retryExport}
|
||||
progress={progress}
|
||||
/>
|
||||
{ConfirmModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (data && data.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<Alert type="warning" message={t('The query returned no data')} />
|
||||
<StreamingExportModal
|
||||
visible={showStreamingModal}
|
||||
onCancel={handleCloseStreamingModal}
|
||||
onRetry={retryExport}
|
||||
progress={progress}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return <Alert type="warning" message={t('The query returned no data')} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (query.cached || (query.state === QueryState.Success && !query.results)) {
|
||||
if (query.isDataPreview) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
buttonSize="small"
|
||||
buttonStyle="primary"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
reFetchQueryResults({
|
||||
...query,
|
||||
isDataPreview: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('Fetch data preview')}
|
||||
</Button>
|
||||
<StreamingExportModal
|
||||
visible={showStreamingModal}
|
||||
onCancel={handleCloseStreamingModal}
|
||||
onRetry={retryExport}
|
||||
progress={progress}
|
||||
/>
|
||||
</>
|
||||
<Button
|
||||
buttonSize="small"
|
||||
buttonStyle="primary"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
reFetchQueryResults({
|
||||
...query,
|
||||
isDataPreview: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('Fetch data preview')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (query.resultsKey) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
buttonSize="small"
|
||||
buttonStyle="primary"
|
||||
onClick={() => fetchResults(query)}
|
||||
>
|
||||
{t('Refetch results')}
|
||||
</Button>
|
||||
<StreamingExportModal
|
||||
visible={showStreamingModal}
|
||||
onCancel={handleCloseStreamingModal}
|
||||
onRetry={retryExport}
|
||||
progress={progress}
|
||||
/>
|
||||
</>
|
||||
<Button
|
||||
buttonSize="small"
|
||||
buttonStyle="primary"
|
||||
onClick={() => fetchResults(query)}
|
||||
>
|
||||
{t('Refetch results')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -861,24 +774,15 @@ const ResultSet = ({
|
||||
const progressMsg = query?.extra?.progress ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResultlessStyles>
|
||||
<div>{!progressBar && <Loading position="normal" />}</div>
|
||||
{/* show loading bar whenever progress bar is completed but needs time to render */}
|
||||
<div>{query.progress === 100 && <Loading position="normal" />}</div>
|
||||
<QueryStateLabel query={query} />
|
||||
<div>
|
||||
{progressMsg && <Alert type="success" message={progressMsg} />}
|
||||
</div>
|
||||
<div>{query.progress !== 100 && progressBar}</div>
|
||||
{trackingUrl && <div>{trackingUrl}</div>}
|
||||
</ResultlessStyles>
|
||||
<StreamingExportModal
|
||||
visible={showStreamingModal}
|
||||
onCancel={handleCloseStreamingModal}
|
||||
progress={progress}
|
||||
/>
|
||||
</>
|
||||
<ResultlessStyles>
|
||||
<div>{!progressBar && <Loading position="normal" />}</div>
|
||||
{/* show loading bar whenever progress bar is completed but needs time to render */}
|
||||
<div>{query.progress === 100 && <Loading position="normal" />}</div>
|
||||
<QueryStateLabel query={query} />
|
||||
<div>{progressMsg && <Alert type="success" message={progressMsg} />}</div>
|
||||
<div>{query.progress !== 100 && progressBar}</div>
|
||||
{trackingUrl && <div>{trackingUrl}</div>}
|
||||
</ResultlessStyles>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -31,8 +31,6 @@ import { SqlLabRootState } from 'src/SqlLab/types';
|
||||
import { useExtensionsContext } from 'src/extensions/ExtensionsContext';
|
||||
import ExtensionsManager from 'src/extensions/ExtensionsManager';
|
||||
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
|
||||
import useLogAction from 'src/logger/useLogAction';
|
||||
import { LOG_ACTIONS_SQLLAB_SWITCH_SOUTH_PANE_TAB } from 'src/logger/LogUtils';
|
||||
import QueryHistory from '../QueryHistory';
|
||||
import {
|
||||
STATUS_OPTIONS,
|
||||
@@ -128,10 +126,8 @@ const SouthPane = ({
|
||||
[pinnedTables],
|
||||
);
|
||||
const southPaneRef = createRef<HTMLDivElement>();
|
||||
const logAction = useLogAction({ sqlEditorId: queryEditorId });
|
||||
const switchTab = (id: string) => {
|
||||
dispatch(setActiveSouthPaneTab(id));
|
||||
logAction(LOG_ACTIONS_SQLLAB_SWITCH_SOUTH_PANE_TAB, { tab: id });
|
||||
};
|
||||
const removeTable = useCallback(
|
||||
(key, action) => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -90,16 +90,11 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
|
||||
findPermission('can_explore', 'Superset', state.user?.roles),
|
||||
);
|
||||
|
||||
const [datasourceIdStr, datasource_type] = formData.datasource.split('__');
|
||||
// Try to parse as integer, fall back to string (UUID) if NaN
|
||||
const parsedDatasourceId = parseInt(datasourceIdStr, 10);
|
||||
const datasource_id = Number.isNaN(parsedDatasourceId)
|
||||
? datasourceIdStr
|
||||
: parsedDatasourceId;
|
||||
const [datasource_id, datasource_type] = formData.datasource.split('__');
|
||||
useEffect(() => {
|
||||
// short circuit if the user is embedded as explore is not available
|
||||
if (isEmbedded()) return;
|
||||
postFormData(datasource_id, datasource_type, formData, 0)
|
||||
postFormData(Number(datasource_id), datasource_type, formData, 0)
|
||||
.then(key => {
|
||||
setUrl(
|
||||
`/explore/?form_data_key=${key}&dashboard_page_id=${dashboardPageId}`,
|
||||
|
||||
@@ -407,12 +407,10 @@ export function exploreJSON(
|
||||
ownState,
|
||||
) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const logStart = Logger.getTimestamp();
|
||||
const controller = new AbortController();
|
||||
const prevController = state.charts?.[key]?.queryController;
|
||||
const queryTimeout =
|
||||
timeout || state.common.conf.SUPERSET_WEBSERVER_TIMEOUT;
|
||||
timeout || getState().common.conf.SUPERSET_WEBSERVER_TIMEOUT;
|
||||
|
||||
const requestParams = {
|
||||
signal: controller.signal,
|
||||
@@ -424,14 +422,6 @@ export function exploreJSON(
|
||||
dispatch(updateDataMask(formData.slice_id, dataMask));
|
||||
};
|
||||
dispatch(chartUpdateStarted(controller, formData, key));
|
||||
/**
|
||||
* Abort in-flight requests after the new controller has been stored in
|
||||
* state. Delaying ensures we do not mutate the Redux state between
|
||||
* dispatches while still cancelling the previous request promptly.
|
||||
*/
|
||||
if (prevController) {
|
||||
setTimeout(() => prevController.abort(), 0);
|
||||
}
|
||||
|
||||
const chartDataRequest = getChartDataRequest({
|
||||
setDataMask,
|
||||
|
||||
@@ -31,7 +31,6 @@ import * as exploreUtils from 'src/explore/exploreUtils';
|
||||
import * as actions from 'src/components/Chart/chartAction';
|
||||
import * as asyncEvent from 'src/middleware/asyncEvent';
|
||||
import { handleChartDataResponse } from 'src/components/Chart/chartAction';
|
||||
import * as dataMaskActions from 'src/dataMask/actions';
|
||||
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
@@ -111,69 +110,6 @@ describe('chart actions', () => {
|
||||
.callsFake(data => Promise.resolve(data));
|
||||
});
|
||||
|
||||
test('should defer abort of previous controller to avoid Redux state mutation', async () => {
|
||||
jest.useFakeTimers();
|
||||
const chartKey = 'defer_abort_test';
|
||||
const formData = {
|
||||
slice_id: 123,
|
||||
datasource: 'table__1',
|
||||
viz_type: 'table',
|
||||
};
|
||||
const oldController = new AbortController();
|
||||
const abortSpy = jest.spyOn(oldController, 'abort');
|
||||
const state = {
|
||||
charts: {
|
||||
[chartKey]: {
|
||||
queryController: oldController,
|
||||
},
|
||||
},
|
||||
common: {
|
||||
conf: {
|
||||
SUPERSET_WEBSERVER_TIMEOUT: 60,
|
||||
},
|
||||
},
|
||||
};
|
||||
const getState = jest.fn(() => state);
|
||||
const dispatchMock = jest.fn();
|
||||
const getChartDataRequestSpy = jest
|
||||
.spyOn(actions, 'getChartDataRequest')
|
||||
.mockResolvedValue({
|
||||
response: { status: 200 },
|
||||
json: { result: [] },
|
||||
});
|
||||
const handleChartDataResponseSpy = jest
|
||||
.spyOn(actions, 'handleChartDataResponse')
|
||||
.mockResolvedValue([]);
|
||||
const updateDataMaskSpy = jest
|
||||
.spyOn(dataMaskActions, 'updateDataMask')
|
||||
.mockReturnValue({ type: 'UPDATE_DATA_MASK' });
|
||||
const getQuerySettingsStub = sinon
|
||||
.stub(exploreUtils, 'getQuerySettings')
|
||||
.returns([false, () => {}]);
|
||||
|
||||
try {
|
||||
const thunk = actions.exploreJSON(formData, false, undefined, chartKey);
|
||||
const promise = thunk(dispatchMock, getState);
|
||||
|
||||
expect(abortSpy).not.toHaveBeenCalled();
|
||||
expect(oldController.signal.aborted).toBe(false);
|
||||
|
||||
jest.runOnlyPendingTimers();
|
||||
|
||||
expect(abortSpy).toHaveBeenCalledTimes(1);
|
||||
expect(oldController.signal.aborted).toBe(true);
|
||||
|
||||
await promise;
|
||||
} finally {
|
||||
getChartDataRequestSpy.mockRestore();
|
||||
handleChartDataResponseSpy.mockRestore();
|
||||
updateDataMaskSpy.mockRestore();
|
||||
getQuerySettingsStub.restore();
|
||||
abortSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getExploreUrlStub.restore();
|
||||
getChartDataUriStub.restore();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,244 +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, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import StreamingExportModal, {
|
||||
ExportStatus,
|
||||
StreamingProgress,
|
||||
} from './StreamingExportModal';
|
||||
|
||||
const defaultProgress: StreamingProgress = {
|
||||
rowsProcessed: 0,
|
||||
totalRows: 1000,
|
||||
totalSize: 0,
|
||||
status: ExportStatus.STREAMING,
|
||||
filename: 'test_export.csv',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
visible: true,
|
||||
onCancel: jest.fn(),
|
||||
onRetry: jest.fn(),
|
||||
progress: defaultProgress,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
URL.revokeObjectURL = jest.fn();
|
||||
URL.createObjectURL = jest.fn(() => 'blob:mock-url');
|
||||
});
|
||||
|
||||
test('renders modal with streaming state', () => {
|
||||
render(<StreamingExportModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('CSV Export')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Processing export for test_export.csv/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Download' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('shows progress percentage during streaming', () => {
|
||||
const progress = {
|
||||
...defaultProgress,
|
||||
rowsProcessed: 500,
|
||||
totalRows: 1000,
|
||||
status: ExportStatus.STREAMING,
|
||||
};
|
||||
|
||||
render(<StreamingExportModal {...defaultProps} progress={progress} />);
|
||||
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows completed state when export finishes', () => {
|
||||
const progress = {
|
||||
...defaultProgress,
|
||||
rowsProcessed: 1000,
|
||||
totalRows: 1000,
|
||||
status: ExportStatus.COMPLETED,
|
||||
downloadUrl: 'blob:mock-url',
|
||||
};
|
||||
|
||||
render(<StreamingExportModal {...defaultProps} progress={progress} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Export successful: test_export.csv/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Download' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('shows error state when export fails', () => {
|
||||
const progress = {
|
||||
...defaultProgress,
|
||||
status: ExportStatus.ERROR,
|
||||
error: 'Database connection failed',
|
||||
};
|
||||
|
||||
render(<StreamingExportModal {...defaultProps} progress={progress} />);
|
||||
|
||||
expect(screen.getByText('Database connection failed')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows cancelled state when export is cancelled', () => {
|
||||
const progress = {
|
||||
...defaultProgress,
|
||||
status: ExportStatus.CANCELLED,
|
||||
};
|
||||
|
||||
render(<StreamingExportModal {...defaultProps} progress={progress} />);
|
||||
|
||||
expect(screen.getByText('Export cancelled')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: 'Close' })[0],
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onCancel when cancel button is clicked during streaming', async () => {
|
||||
const onCancel = jest.fn();
|
||||
render(<StreamingExportModal {...defaultProps} onCancel={onCancel} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('calls onRetry when retry button is clicked after error', async () => {
|
||||
const onRetry = jest.fn();
|
||||
const progress = {
|
||||
...defaultProgress,
|
||||
status: ExportStatus.ERROR,
|
||||
error: 'Network error',
|
||||
};
|
||||
|
||||
render(
|
||||
<StreamingExportModal
|
||||
{...defaultProps}
|
||||
progress={progress}
|
||||
onRetry={onRetry}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('triggers download when download button is clicked', async () => {
|
||||
const progress = {
|
||||
...defaultProgress,
|
||||
rowsProcessed: 1000,
|
||||
totalRows: 1000,
|
||||
status: ExportStatus.COMPLETED,
|
||||
downloadUrl: 'blob:mock-url',
|
||||
filename: 'test_export.csv',
|
||||
};
|
||||
|
||||
const onCancel = jest.fn();
|
||||
render(
|
||||
<StreamingExportModal
|
||||
{...defaultProps}
|
||||
progress={progress}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
const downloadButton = screen.getByRole('button', { name: 'Download' });
|
||||
expect(downloadButton).toBeEnabled();
|
||||
|
||||
await userEvent.click(downloadButton);
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('does not show download button when downloadUrl is missing', () => {
|
||||
const progress = {
|
||||
...defaultProgress,
|
||||
status: ExportStatus.COMPLETED,
|
||||
};
|
||||
|
||||
render(<StreamingExportModal {...defaultProps} progress={progress} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Download' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('progress bar shows correct percentage with decimal precision', () => {
|
||||
const progress = {
|
||||
...defaultProgress,
|
||||
rowsProcessed: 333,
|
||||
totalRows: 1000,
|
||||
status: ExportStatus.STREAMING,
|
||||
};
|
||||
|
||||
render(<StreamingExportModal {...defaultProps} progress={progress} />);
|
||||
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows generic processing message when filename is not provided', () => {
|
||||
const progress = {
|
||||
...defaultProgress,
|
||||
filename: undefined,
|
||||
status: ExportStatus.STREAMING,
|
||||
};
|
||||
|
||||
render(<StreamingExportModal {...defaultProps} progress={progress} />);
|
||||
|
||||
expect(screen.getByText('Processing export...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles retry button visibility based on onRetry prop', () => {
|
||||
const progress = {
|
||||
...defaultProgress,
|
||||
status: ExportStatus.ERROR,
|
||||
error: 'Test error',
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<StreamingExportModal
|
||||
{...defaultProps}
|
||||
progress={progress}
|
||||
onRetry={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Retry' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<StreamingExportModal
|
||||
{...defaultProps}
|
||||
progress={progress}
|
||||
onRetry={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows generic error message when error details are not provided', () => {
|
||||
const progress = {
|
||||
...defaultProgress,
|
||||
status: ExportStatus.ERROR,
|
||||
};
|
||||
|
||||
render(<StreamingExportModal {...defaultProps} progress={progress} />);
|
||||
|
||||
expect(screen.getByText('Export failed')).toBeInTheDocument();
|
||||
});
|
||||
@@ -1,380 +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 { t } from '@superset-ui/core';
|
||||
import { styled, useTheme } from '@apache-superset/core/ui';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Typography,
|
||||
Progress,
|
||||
} from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export enum ExportStatus {
|
||||
STREAMING = 'streaming',
|
||||
COMPLETED = 'completed',
|
||||
ERROR = 'error',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
const COMPLETED_PERCENT = 100;
|
||||
|
||||
export interface StreamingProgress {
|
||||
totalRows?: number;
|
||||
rowsProcessed: number;
|
||||
totalSize: number;
|
||||
status: ExportStatus;
|
||||
downloadUrl?: string;
|
||||
error?: string;
|
||||
filename?: string;
|
||||
speed?: number;
|
||||
mbPerSecond?: number;
|
||||
elapsedTime?: number;
|
||||
retryCount?: number;
|
||||
}
|
||||
|
||||
interface StreamingExportModalProps {
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
onRetry?: () => void;
|
||||
onDownload?: () => void;
|
||||
progress: StreamingProgress;
|
||||
}
|
||||
|
||||
const ModalContent = styled.div`
|
||||
${({ theme }) => `
|
||||
padding: ${theme.sizeUnit * 4}px 0 ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const ProgressSection = styled.div`
|
||||
${({ theme }) => `
|
||||
margin: ${theme.sizeUnit * 6}px 0;
|
||||
position: relative;
|
||||
`}
|
||||
`;
|
||||
|
||||
const ProgressWrapper = styled.div`
|
||||
${({ theme }) => `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit * 3}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledProgress = styled(Progress)`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const SuccessIcon = styled(Icons.CheckCircleFilled)`
|
||||
${({ theme }) => `
|
||||
color: ${theme.colorSuccess};
|
||||
font-size: ${theme.sizeUnit * 6}px;
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
const ErrorIconWrapper = styled.div`
|
||||
${({ theme }) => `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: ${theme.sizeUnit * 4}px;
|
||||
height: ${theme.sizeUnit * 4}px;
|
||||
background-color: ${theme.colorError};
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
const ErrorIconStyled = styled(Icons.CloseOutlined)`
|
||||
${({ theme }) => `
|
||||
color: ${theme.colorWhite};
|
||||
font-size: ${theme.sizeUnit * 2.5}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const ActionButtons = styled.div`
|
||||
${({ theme }) => `
|
||||
display: flex;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
justify-content: flex-end;
|
||||
`}
|
||||
`;
|
||||
|
||||
const CenteredText = styled(Text)`
|
||||
${({ theme }) => `
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const ErrorText = styled(CenteredText)`
|
||||
${({ theme }) => `
|
||||
color: ${theme.colorError};
|
||||
`}
|
||||
`;
|
||||
|
||||
const CancelButton = styled(Button)`
|
||||
${({ theme }) => `
|
||||
background-color: ${theme.colorSuccessBg};
|
||||
color: ${theme.colorSuccess};
|
||||
border-color: ${theme.colorSuccessBg};
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.colorSuccessBg};
|
||||
color: ${theme.colorSuccess};
|
||||
border-color: ${theme.colorSuccess};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: ${theme.colorSuccessBg};
|
||||
color: ${theme.colorSuccess};
|
||||
border-color: ${theme.colorSuccess};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const DownloadButton = styled(Button)`
|
||||
${({ theme }) => `
|
||||
background-color: ${theme.colorSuccess};
|
||||
border-color: ${theme.colorSuccess};
|
||||
color: ${theme.colorWhite};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${theme.colorSuccessActive};
|
||||
border-color: ${theme.colorSuccessActive};
|
||||
color: ${theme.colorWhite};
|
||||
}
|
||||
|
||||
&:focus:not(:disabled) {
|
||||
background-color: ${theme.colorSuccess};
|
||||
border-color: ${theme.colorSuccess};
|
||||
color: ${theme.colorWhite};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: ${theme.colorBgContainerDisabled};
|
||||
border-color: ${theme.colorBgContainerDisabled};
|
||||
color: ${theme.colorTextDisabled};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const triggerFileDownload = (url: string, filename: string) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const calculateProgressPercentage = (
|
||||
status: ExportStatus,
|
||||
totalRows?: number,
|
||||
rowsProcessed?: number,
|
||||
): number => {
|
||||
if (status === ExportStatus.COMPLETED) return COMPLETED_PERCENT;
|
||||
|
||||
if (!totalRows || totalRows <= 0 || !rowsProcessed) return 0;
|
||||
|
||||
const percentage = (rowsProcessed / totalRows) * 100;
|
||||
return Math.floor(percentage);
|
||||
};
|
||||
|
||||
const getProgressStatus = (
|
||||
status: ExportStatus,
|
||||
): 'success' | 'exception' | 'normal' => {
|
||||
switch (status) {
|
||||
case ExportStatus.COMPLETED:
|
||||
return 'success';
|
||||
case ExportStatus.ERROR:
|
||||
case ExportStatus.CANCELLED:
|
||||
return 'exception';
|
||||
case ExportStatus.STREAMING:
|
||||
default:
|
||||
return 'normal';
|
||||
}
|
||||
};
|
||||
|
||||
const getMessageText = (
|
||||
status: ExportStatus,
|
||||
filename?: string,
|
||||
error?: string,
|
||||
): string => {
|
||||
switch (status) {
|
||||
case ExportStatus.ERROR:
|
||||
return error || t('Export failed');
|
||||
case ExportStatus.CANCELLED:
|
||||
return t('Export cancelled');
|
||||
case ExportStatus.COMPLETED:
|
||||
return t('Export successful: %s', filename || 'export');
|
||||
case ExportStatus.STREAMING:
|
||||
default:
|
||||
return filename
|
||||
? t('Processing export for %s', filename)
|
||||
: t('Processing export...');
|
||||
}
|
||||
};
|
||||
|
||||
const getButtonText = (status: ExportStatus): string => {
|
||||
switch (status) {
|
||||
case ExportStatus.ERROR:
|
||||
case ExportStatus.CANCELLED:
|
||||
case ExportStatus.COMPLETED:
|
||||
return t('Close');
|
||||
case ExportStatus.STREAMING:
|
||||
default:
|
||||
return t('Cancel');
|
||||
}
|
||||
};
|
||||
|
||||
interface ModalStateContentProps {
|
||||
status: ExportStatus;
|
||||
progress: StreamingProgress;
|
||||
onCancel: () => void;
|
||||
onRetry?: () => void;
|
||||
onDownload: () => void;
|
||||
getProgressPercentage: () => number;
|
||||
}
|
||||
|
||||
const ModalStateContent = ({
|
||||
status,
|
||||
progress,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onDownload,
|
||||
getProgressPercentage,
|
||||
}: ModalStateContentProps) => {
|
||||
const theme = useTheme();
|
||||
const { downloadUrl, filename, error } = progress;
|
||||
|
||||
const isError = status === ExportStatus.ERROR;
|
||||
const isCancelled = status === ExportStatus.CANCELLED;
|
||||
const isCompleted = status === ExportStatus.COMPLETED;
|
||||
const isStreaming = status === ExportStatus.STREAMING;
|
||||
|
||||
const hasIcon = isError || isCompleted;
|
||||
const shouldShowRetry = (isError || isCancelled) && onRetry;
|
||||
|
||||
const progressStatus = getProgressStatus(status);
|
||||
const progressPercent = isCompleted ? 100 : getProgressPercentage();
|
||||
const messageText = getMessageText(status, filename, error);
|
||||
const buttonText = getButtonText(status);
|
||||
|
||||
const progressProps = {
|
||||
percent: progressPercent,
|
||||
status: progressStatus,
|
||||
showInfo: isStreaming,
|
||||
...(isStreaming && {
|
||||
strokeColor: theme.colorSuccess,
|
||||
format: (percent?: number) => `${Math.round(percent || 0)}%`,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalContent>
|
||||
<ProgressSection>
|
||||
{hasIcon ? (
|
||||
<ProgressWrapper>
|
||||
<StyledProgress {...progressProps} />
|
||||
{isError && (
|
||||
<ErrorIconWrapper>
|
||||
<ErrorIconStyled />
|
||||
</ErrorIconWrapper>
|
||||
)}
|
||||
{isCompleted && <SuccessIcon />}
|
||||
</ProgressWrapper>
|
||||
) : (
|
||||
<Progress {...progressProps} />
|
||||
)}
|
||||
{isError ? (
|
||||
<ErrorText>{messageText}</ErrorText>
|
||||
) : (
|
||||
<CenteredText>{messageText}</CenteredText>
|
||||
)}
|
||||
</ProgressSection>
|
||||
<ActionButtons>
|
||||
<CancelButton onClick={onCancel}>{buttonText}</CancelButton>
|
||||
{shouldShowRetry ? (
|
||||
<DownloadButton onClick={onRetry}>{t('Retry')}</DownloadButton>
|
||||
) : (
|
||||
<DownloadButton
|
||||
onClick={onDownload}
|
||||
disabled={!isCompleted || !downloadUrl}
|
||||
>
|
||||
{t('Download')}
|
||||
</DownloadButton>
|
||||
)}
|
||||
</ActionButtons>
|
||||
</ModalContent>
|
||||
);
|
||||
};
|
||||
|
||||
const StreamingExportModal = ({
|
||||
visible,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onDownload,
|
||||
progress,
|
||||
}: StreamingExportModalProps) => {
|
||||
const { status, downloadUrl, filename } = progress;
|
||||
|
||||
const getProgressPercentage = (): number =>
|
||||
calculateProgressPercentage(
|
||||
status,
|
||||
progress.totalRows,
|
||||
progress.rowsProcessed,
|
||||
);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (downloadUrl && filename) {
|
||||
triggerFileDownload(downloadUrl, filename);
|
||||
onDownload?.(); // Call onDownload callback if provided
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('CSV Export')}
|
||||
show={visible}
|
||||
onHide={onCancel}
|
||||
hideFooter
|
||||
width={600}
|
||||
maskClosable={false}
|
||||
centered
|
||||
>
|
||||
<ModalStateContent
|
||||
status={status}
|
||||
progress={progress}
|
||||
onCancel={onCancel}
|
||||
onRetry={onRetry}
|
||||
onDownload={handleDownload}
|
||||
getProgressPercentage={getProgressPercentage}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreamingExportModal;
|
||||
@@ -1,21 +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.
|
||||
*/
|
||||
export { default as StreamingExportModal } from './StreamingExportModal';
|
||||
export type { StreamingProgress } from './StreamingExportModal';
|
||||
export { useStreamingExport } from './useStreamingExport';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user